Skip to content

Commit 087e6f7

Browse files
committed
feat: add spell checking to prompt input area
- Implement useSpellCheck hook for spell checking functionality - Add SpellCheckSuggestions component for displaying corrections - Integrate spell checking into ChatTextArea component - Add wavy underline styles for misspelled words - Include comprehensive test suite for spell checking - Addresses issue #8275
1 parent 8dbd8c4 commit 087e6f7

File tree

5 files changed

+724
-3
lines changed

5 files changed

+724
-3
lines changed

webview-ui/src/components/chat/ChatTextArea.tsx

Lines changed: 119 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
import { cn } from "@src/lib/utils"
2323
import { convertToMentionPath } from "@src/utils/path-mentions"
2424
import { StandardTooltip } from "@src/components/ui"
25+
import { useSpellCheck, SpellCheckResult } from "@src/hooks/useSpellCheck"
2526

2627
import Thumbnails from "../common/Thumbnails"
2728
import { ModeSelector } from "./ModeSelector"
@@ -32,6 +33,7 @@ import ContextMenu from "./ContextMenu"
3233
import { IndexingStatusBadge } from "./IndexingStatusBadge"
3334
import { usePromptHistory } from "./hooks/usePromptHistory"
3435
import { CloudAccountSwitcher } from "../cloud/CloudAccountSwitcher"
36+
import { SpellCheckSuggestions } from "./SpellCheckSuggestions"
3537

3638
interface ChatTextAreaProps {
3739
inputValue: string
@@ -215,6 +217,18 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
215217
const [isEnhancingPrompt, setIsEnhancingPrompt] = useState(false)
216218
const [isFocused, setIsFocused] = useState(false)
217219

220+
// Spell check state
221+
const {
222+
misspelledWords,
223+
checkSpelling,
224+
isSupported: isSpellCheckSupported,
225+
} = useSpellCheck({
226+
enabled: true,
227+
debounceMs: 500,
228+
})
229+
const [selectedMisspelling, setSelectedMisspelling] = useState<SpellCheckResult | null>(null)
230+
const [spellCheckMenuPosition, setSpellCheckMenuPosition] = useState<{ x: number; y: number } | null>(null)
231+
218232
// Use custom hook for prompt history navigation
219233
const { handleHistoryNavigation, resetHistoryNavigation, resetOnInputChange } = usePromptHistory({
220234
clineMessages,
@@ -558,6 +572,11 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
558572
// Reset history navigation when user types
559573
resetOnInputChange()
560574

575+
// Check spelling when text changes
576+
if (isSpellCheckSupported) {
577+
checkSpelling(newValue)
578+
}
579+
561580
const newCursorPosition = e.target.selectionStart
562581
setCursorPosition(newCursorPosition)
563582

@@ -615,7 +634,15 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
615634
setFileSearchResults([]) // Clear file search results.
616635
}
617636
},
618-
[setInputValue, setSearchRequestId, setFileSearchResults, setSearchLoading, resetOnInputChange],
637+
[
638+
setInputValue,
639+
setSearchRequestId,
640+
setFileSearchResults,
641+
setSearchLoading,
642+
resetOnInputChange,
643+
isSpellCheckSupported,
644+
checkSpelling,
645+
],
619646
)
620647

621648
useEffect(() => {
@@ -750,15 +777,35 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
750777
return match // Return unhighlighted if command is not valid
751778
})
752779

780+
// Add spell check highlights
781+
if (isSpellCheckSupported && misspelledWords.length > 0) {
782+
// Sort misspellings by position (reverse order to maintain positions)
783+
const sortedMisspellings = [...misspelledWords].sort((a, b) => b.start - a.start)
784+
785+
sortedMisspellings.forEach((misspelling) => {
786+
const before = processedText.slice(0, misspelling.start)
787+
const word = processedText.slice(misspelling.start, misspelling.end)
788+
const after = processedText.slice(misspelling.end)
789+
790+
// Only add spell check highlight if the word isn't already highlighted
791+
if (!word.includes("<mark")) {
792+
processedText =
793+
before +
794+
`<span class="spell-check-underline" data-word="${misspelling.word}" data-start="${misspelling.start}" data-end="${misspelling.end}">${word}</span>` +
795+
after
796+
}
797+
})
798+
}
799+
753800
highlightLayerRef.current.innerHTML = processedText
754801

755802
highlightLayerRef.current.scrollTop = textAreaRef.current.scrollTop
756803
highlightLayerRef.current.scrollLeft = textAreaRef.current.scrollLeft
757-
}, [commands])
804+
}, [commands, isSpellCheckSupported, misspelledWords])
758805

759806
useLayoutEffect(() => {
760807
updateHighlights()
761-
}, [inputValue, updateHighlights])
808+
}, [inputValue, updateHighlights, misspelledWords])
762809

763810
const updateCursorPosition = useCallback(() => {
764811
if (textAreaRef.current) {
@@ -903,6 +950,63 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
903950
[setMode],
904951
)
905952

953+
// Handle spell check word click
954+
const handleSpellCheckClick = useCallback(
955+
(event: React.MouseEvent) => {
956+
const target = event.target as HTMLElement
957+
if (target.classList.contains("spell-check-underline")) {
958+
const word = target.getAttribute("data-word")
959+
const start = parseInt(target.getAttribute("data-start") || "0", 10)
960+
const end = parseInt(target.getAttribute("data-end") || "0", 10)
961+
962+
const misspelling = misspelledWords.find(
963+
(m) => m.word === word && m.start === start && m.end === end,
964+
)
965+
966+
if (misspelling) {
967+
const rect = target.getBoundingClientRect()
968+
setSelectedMisspelling(misspelling)
969+
setSpellCheckMenuPosition({
970+
x: rect.left,
971+
y: rect.bottom + 5,
972+
})
973+
}
974+
}
975+
},
976+
[misspelledWords],
977+
)
978+
979+
// Handle spell check suggestion selection
980+
const handleSpellCheckSuggestionSelect = useCallback(
981+
(suggestion: string) => {
982+
if (selectedMisspelling && textAreaRef.current) {
983+
const currentValue = inputValue
984+
const before = currentValue.slice(0, selectedMisspelling.start)
985+
const after = currentValue.slice(selectedMisspelling.end)
986+
const newValue = before + suggestion + after
987+
988+
setInputValue(newValue)
989+
setSelectedMisspelling(null)
990+
setSpellCheckMenuPosition(null)
991+
992+
// Update cursor position
993+
const newCursorPos = selectedMisspelling.start + suggestion.length
994+
setTimeout(() => {
995+
if (textAreaRef.current) {
996+
textAreaRef.current.focus()
997+
textAreaRef.current.setSelectionRange(newCursorPos, newCursorPos)
998+
}
999+
}, 0)
1000+
1001+
// Re-check spelling with the corrected text
1002+
if (isSpellCheckSupported) {
1003+
checkSpelling(newValue)
1004+
}
1005+
}
1006+
},
1007+
[selectedMisspelling, inputValue, setInputValue, isSpellCheckSupported, checkSpelling],
1008+
)
1009+
9061010
// Helper function to handle API config change
9071011
const handleApiConfigChange = useCallback((value: string) => {
9081012
vscode.postMessage({ type: "loadApiConfigurationById", text: value })
@@ -1011,6 +1115,7 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
10111115
style={{
10121116
color: "transparent",
10131117
}}
1118+
onClick={handleSpellCheckClick}
10141119
/>
10151120
<DynamicTextArea
10161121
ref={(el) => {
@@ -1268,6 +1373,17 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
12681373
)}
12691374
</div>
12701375
</div>
1376+
1377+
{/* Spell check suggestions menu */}
1378+
<SpellCheckSuggestions
1379+
misspelling={selectedMisspelling}
1380+
position={spellCheckMenuPosition}
1381+
onSelect={handleSpellCheckSuggestionSelect}
1382+
onDismiss={() => {
1383+
setSelectedMisspelling(null)
1384+
setSpellCheckMenuPosition(null)
1385+
}}
1386+
/>
12711387
</div>
12721388
)
12731389
},
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import React, { useCallback, useEffect, useRef, useState } from "react"
2+
import { cn } from "@src/lib/utils"
3+
import { SpellCheckResult } from "@src/hooks/useSpellCheck"
4+
5+
interface SpellCheckSuggestionsProps {
6+
misspelling: SpellCheckResult | null
7+
position: { x: number; y: number } | null
8+
onSelect: (suggestion: string) => void
9+
onDismiss: () => void
10+
onIgnore?: () => void
11+
onAddToDictionary?: () => void
12+
}
13+
14+
export const SpellCheckSuggestions: React.FC<SpellCheckSuggestionsProps> = ({
15+
misspelling,
16+
position,
17+
onSelect,
18+
onDismiss,
19+
onIgnore,
20+
onAddToDictionary,
21+
}) => {
22+
const menuRef = useRef<HTMLDivElement>(null)
23+
const [selectedIndex, setSelectedIndex] = useState(0)
24+
25+
// Reset selected index when misspelling changes
26+
useEffect(() => {
27+
setSelectedIndex(0)
28+
}, [misspelling])
29+
30+
// Handle click outside
31+
useEffect(() => {
32+
const handleClickOutside = (event: MouseEvent) => {
33+
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
34+
onDismiss()
35+
}
36+
}
37+
38+
if (misspelling && position) {
39+
document.addEventListener("mousedown", handleClickOutside)
40+
return () => {
41+
document.removeEventListener("mousedown", handleClickOutside)
42+
}
43+
}
44+
}, [misspelling, position, onDismiss])
45+
46+
// Handle keyboard navigation
47+
const handleKeyDown = useCallback(
48+
(event: KeyboardEvent) => {
49+
if (!misspelling) return
50+
51+
const totalItems = misspelling.suggestions.length + (onIgnore ? 1 : 0) + (onAddToDictionary ? 1 : 0)
52+
53+
switch (event.key) {
54+
case "ArrowUp":
55+
event.preventDefault()
56+
setSelectedIndex((prev) => (prev - 1 + totalItems) % totalItems)
57+
break
58+
case "ArrowDown":
59+
event.preventDefault()
60+
setSelectedIndex((prev) => (prev + 1) % totalItems)
61+
break
62+
case "Enter":
63+
event.preventDefault()
64+
if (selectedIndex < misspelling.suggestions.length) {
65+
onSelect(misspelling.suggestions[selectedIndex])
66+
} else if (selectedIndex === misspelling.suggestions.length && onIgnore) {
67+
onIgnore()
68+
} else if (onAddToDictionary) {
69+
onAddToDictionary()
70+
}
71+
break
72+
case "Escape":
73+
event.preventDefault()
74+
onDismiss()
75+
break
76+
}
77+
},
78+
[misspelling, selectedIndex, onSelect, onIgnore, onAddToDictionary, onDismiss],
79+
)
80+
81+
useEffect(() => {
82+
if (misspelling && position) {
83+
document.addEventListener("keydown", handleKeyDown)
84+
return () => {
85+
document.removeEventListener("keydown", handleKeyDown)
86+
}
87+
}
88+
}, [misspelling, position, handleKeyDown])
89+
90+
if (!misspelling || !position) {
91+
return null
92+
}
93+
94+
const menuItems = [
95+
...misspelling.suggestions.map((suggestion, index) => ({
96+
label: suggestion,
97+
onClick: () => onSelect(suggestion),
98+
isSelected: selectedIndex === index,
99+
className: "font-medium",
100+
})),
101+
]
102+
103+
if (onIgnore) {
104+
menuItems.push({
105+
label: "Ignore",
106+
onClick: onIgnore,
107+
isSelected: selectedIndex === misspelling.suggestions.length,
108+
className: "text-vscode-descriptionForeground",
109+
})
110+
}
111+
112+
if (onAddToDictionary) {
113+
menuItems.push({
114+
label: "Add to dictionary",
115+
onClick: onAddToDictionary,
116+
isSelected: selectedIndex === misspelling.suggestions.length + (onIgnore ? 1 : 0),
117+
className: "text-vscode-descriptionForeground",
118+
})
119+
}
120+
121+
return (
122+
<div
123+
ref={menuRef}
124+
className={cn(
125+
"absolute z-50",
126+
"min-w-[150px] max-w-[250px]",
127+
"bg-vscode-dropdown-background",
128+
"border border-vscode-dropdown-border",
129+
"rounded-md",
130+
"shadow-lg",
131+
"py-1",
132+
"animate-fade-in",
133+
)}
134+
style={{
135+
left: `${position.x}px`,
136+
top: `${position.y}px`,
137+
}}
138+
role="menu"
139+
aria-label="Spelling suggestions">
140+
{misspelling.suggestions.length === 0 ? (
141+
<div className="px-3 py-2 text-vscode-descriptionForeground text-sm">No suggestions available</div>
142+
) : (
143+
menuItems.map((item, index) => (
144+
<button
145+
key={index}
146+
className={cn(
147+
"w-full text-left px-3 py-1.5",
148+
"text-sm",
149+
"hover:bg-vscode-list-hoverBackground",
150+
"focus:bg-vscode-list-focusBackground",
151+
"focus:outline-none",
152+
"transition-colors",
153+
item.className,
154+
item.isSelected && "bg-vscode-list-activeSelectionBackground",
155+
)}
156+
onClick={item.onClick}
157+
onMouseEnter={() => setSelectedIndex(index)}
158+
role="menuitem">
159+
{item.label}
160+
</button>
161+
))
162+
)}
163+
</div>
164+
)
165+
}

0 commit comments

Comments
 (0)