mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-06 08:18:39 +00:00
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
This commit is contained in:
parent
8dbd8c4b1b
commit
087e6f77e8
5 changed files with 724 additions and 3 deletions
|
|
@ -22,6 +22,7 @@ import {
|
|||
import { cn } from "@src/lib/utils"
|
||||
import { convertToMentionPath } from "@src/utils/path-mentions"
|
||||
import { StandardTooltip } from "@src/components/ui"
|
||||
import { useSpellCheck, SpellCheckResult } from "@src/hooks/useSpellCheck"
|
||||
|
||||
import Thumbnails from "../common/Thumbnails"
|
||||
import { ModeSelector } from "./ModeSelector"
|
||||
|
|
@ -32,6 +33,7 @@ import ContextMenu from "./ContextMenu"
|
|||
import { IndexingStatusBadge } from "./IndexingStatusBadge"
|
||||
import { usePromptHistory } from "./hooks/usePromptHistory"
|
||||
import { CloudAccountSwitcher } from "../cloud/CloudAccountSwitcher"
|
||||
import { SpellCheckSuggestions } from "./SpellCheckSuggestions"
|
||||
|
||||
interface ChatTextAreaProps {
|
||||
inputValue: string
|
||||
|
|
@ -215,6 +217,18 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
|||
const [isEnhancingPrompt, setIsEnhancingPrompt] = useState(false)
|
||||
const [isFocused, setIsFocused] = useState(false)
|
||||
|
||||
// Spell check state
|
||||
const {
|
||||
misspelledWords,
|
||||
checkSpelling,
|
||||
isSupported: isSpellCheckSupported,
|
||||
} = useSpellCheck({
|
||||
enabled: true,
|
||||
debounceMs: 500,
|
||||
})
|
||||
const [selectedMisspelling, setSelectedMisspelling] = useState<SpellCheckResult | null>(null)
|
||||
const [spellCheckMenuPosition, setSpellCheckMenuPosition] = useState<{ x: number; y: number } | null>(null)
|
||||
|
||||
// Use custom hook for prompt history navigation
|
||||
const { handleHistoryNavigation, resetHistoryNavigation, resetOnInputChange } = usePromptHistory({
|
||||
clineMessages,
|
||||
|
|
@ -558,6 +572,11 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
|||
// Reset history navigation when user types
|
||||
resetOnInputChange()
|
||||
|
||||
// Check spelling when text changes
|
||||
if (isSpellCheckSupported) {
|
||||
checkSpelling(newValue)
|
||||
}
|
||||
|
||||
const newCursorPosition = e.target.selectionStart
|
||||
setCursorPosition(newCursorPosition)
|
||||
|
||||
|
|
@ -615,7 +634,15 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
|||
setFileSearchResults([]) // Clear file search results.
|
||||
}
|
||||
},
|
||||
[setInputValue, setSearchRequestId, setFileSearchResults, setSearchLoading, resetOnInputChange],
|
||||
[
|
||||
setInputValue,
|
||||
setSearchRequestId,
|
||||
setFileSearchResults,
|
||||
setSearchLoading,
|
||||
resetOnInputChange,
|
||||
isSpellCheckSupported,
|
||||
checkSpelling,
|
||||
],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -750,15 +777,35 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
|||
return match // Return unhighlighted if command is not valid
|
||||
})
|
||||
|
||||
// Add spell check highlights
|
||||
if (isSpellCheckSupported && misspelledWords.length > 0) {
|
||||
// Sort misspellings by position (reverse order to maintain positions)
|
||||
const sortedMisspellings = [...misspelledWords].sort((a, b) => b.start - a.start)
|
||||
|
||||
sortedMisspellings.forEach((misspelling) => {
|
||||
const before = processedText.slice(0, misspelling.start)
|
||||
const word = processedText.slice(misspelling.start, misspelling.end)
|
||||
const after = processedText.slice(misspelling.end)
|
||||
|
||||
// Only add spell check highlight if the word isn't already highlighted
|
||||
if (!word.includes("<mark")) {
|
||||
processedText =
|
||||
before +
|
||||
`<span class="spell-check-underline" data-word="${misspelling.word}" data-start="${misspelling.start}" data-end="${misspelling.end}">${word}</span>` +
|
||||
after
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
highlightLayerRef.current.innerHTML = processedText
|
||||
|
||||
highlightLayerRef.current.scrollTop = textAreaRef.current.scrollTop
|
||||
highlightLayerRef.current.scrollLeft = textAreaRef.current.scrollLeft
|
||||
}, [commands])
|
||||
}, [commands, isSpellCheckSupported, misspelledWords])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
updateHighlights()
|
||||
}, [inputValue, updateHighlights])
|
||||
}, [inputValue, updateHighlights, misspelledWords])
|
||||
|
||||
const updateCursorPosition = useCallback(() => {
|
||||
if (textAreaRef.current) {
|
||||
|
|
@ -903,6 +950,63 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
|||
[setMode],
|
||||
)
|
||||
|
||||
// Handle spell check word click
|
||||
const handleSpellCheckClick = useCallback(
|
||||
(event: React.MouseEvent) => {
|
||||
const target = event.target as HTMLElement
|
||||
if (target.classList.contains("spell-check-underline")) {
|
||||
const word = target.getAttribute("data-word")
|
||||
const start = parseInt(target.getAttribute("data-start") || "0", 10)
|
||||
const end = parseInt(target.getAttribute("data-end") || "0", 10)
|
||||
|
||||
const misspelling = misspelledWords.find(
|
||||
(m) => m.word === word && m.start === start && m.end === end,
|
||||
)
|
||||
|
||||
if (misspelling) {
|
||||
const rect = target.getBoundingClientRect()
|
||||
setSelectedMisspelling(misspelling)
|
||||
setSpellCheckMenuPosition({
|
||||
x: rect.left,
|
||||
y: rect.bottom + 5,
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
[misspelledWords],
|
||||
)
|
||||
|
||||
// Handle spell check suggestion selection
|
||||
const handleSpellCheckSuggestionSelect = useCallback(
|
||||
(suggestion: string) => {
|
||||
if (selectedMisspelling && textAreaRef.current) {
|
||||
const currentValue = inputValue
|
||||
const before = currentValue.slice(0, selectedMisspelling.start)
|
||||
const after = currentValue.slice(selectedMisspelling.end)
|
||||
const newValue = before + suggestion + after
|
||||
|
||||
setInputValue(newValue)
|
||||
setSelectedMisspelling(null)
|
||||
setSpellCheckMenuPosition(null)
|
||||
|
||||
// Update cursor position
|
||||
const newCursorPos = selectedMisspelling.start + suggestion.length
|
||||
setTimeout(() => {
|
||||
if (textAreaRef.current) {
|
||||
textAreaRef.current.focus()
|
||||
textAreaRef.current.setSelectionRange(newCursorPos, newCursorPos)
|
||||
}
|
||||
}, 0)
|
||||
|
||||
// Re-check spelling with the corrected text
|
||||
if (isSpellCheckSupported) {
|
||||
checkSpelling(newValue)
|
||||
}
|
||||
}
|
||||
},
|
||||
[selectedMisspelling, inputValue, setInputValue, isSpellCheckSupported, checkSpelling],
|
||||
)
|
||||
|
||||
// Helper function to handle API config change
|
||||
const handleApiConfigChange = useCallback((value: string) => {
|
||||
vscode.postMessage({ type: "loadApiConfigurationById", text: value })
|
||||
|
|
@ -1011,6 +1115,7 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
|||
style={{
|
||||
color: "transparent",
|
||||
}}
|
||||
onClick={handleSpellCheckClick}
|
||||
/>
|
||||
<DynamicTextArea
|
||||
ref={(el) => {
|
||||
|
|
@ -1268,6 +1373,17 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Spell check suggestions menu */}
|
||||
<SpellCheckSuggestions
|
||||
misspelling={selectedMisspelling}
|
||||
position={spellCheckMenuPosition}
|
||||
onSelect={handleSpellCheckSuggestionSelect}
|
||||
onDismiss={() => {
|
||||
setSelectedMisspelling(null)
|
||||
setSpellCheckMenuPosition(null)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
|
|
|
|||
165
webview-ui/src/components/chat/SpellCheckSuggestions.tsx
Normal file
165
webview-ui/src/components/chat/SpellCheckSuggestions.tsx
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
import React, { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { cn } from "@src/lib/utils"
|
||||
import { SpellCheckResult } from "@src/hooks/useSpellCheck"
|
||||
|
||||
interface SpellCheckSuggestionsProps {
|
||||
misspelling: SpellCheckResult | null
|
||||
position: { x: number; y: number } | null
|
||||
onSelect: (suggestion: string) => void
|
||||
onDismiss: () => void
|
||||
onIgnore?: () => void
|
||||
onAddToDictionary?: () => void
|
||||
}
|
||||
|
||||
export const SpellCheckSuggestions: React.FC<SpellCheckSuggestionsProps> = ({
|
||||
misspelling,
|
||||
position,
|
||||
onSelect,
|
||||
onDismiss,
|
||||
onIgnore,
|
||||
onAddToDictionary,
|
||||
}) => {
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
|
||||
// Reset selected index when misspelling changes
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0)
|
||||
}, [misspelling])
|
||||
|
||||
// Handle click outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
|
||||
if (misspelling && position) {
|
||||
document.addEventListener("mousedown", handleClickOutside)
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside)
|
||||
}
|
||||
}
|
||||
}, [misspelling, position, onDismiss])
|
||||
|
||||
// Handle keyboard navigation
|
||||
const handleKeyDown = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
if (!misspelling) return
|
||||
|
||||
const totalItems = misspelling.suggestions.length + (onIgnore ? 1 : 0) + (onAddToDictionary ? 1 : 0)
|
||||
|
||||
switch (event.key) {
|
||||
case "ArrowUp":
|
||||
event.preventDefault()
|
||||
setSelectedIndex((prev) => (prev - 1 + totalItems) % totalItems)
|
||||
break
|
||||
case "ArrowDown":
|
||||
event.preventDefault()
|
||||
setSelectedIndex((prev) => (prev + 1) % totalItems)
|
||||
break
|
||||
case "Enter":
|
||||
event.preventDefault()
|
||||
if (selectedIndex < misspelling.suggestions.length) {
|
||||
onSelect(misspelling.suggestions[selectedIndex])
|
||||
} else if (selectedIndex === misspelling.suggestions.length && onIgnore) {
|
||||
onIgnore()
|
||||
} else if (onAddToDictionary) {
|
||||
onAddToDictionary()
|
||||
}
|
||||
break
|
||||
case "Escape":
|
||||
event.preventDefault()
|
||||
onDismiss()
|
||||
break
|
||||
}
|
||||
},
|
||||
[misspelling, selectedIndex, onSelect, onIgnore, onAddToDictionary, onDismiss],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (misspelling && position) {
|
||||
document.addEventListener("keydown", handleKeyDown)
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleKeyDown)
|
||||
}
|
||||
}
|
||||
}, [misspelling, position, handleKeyDown])
|
||||
|
||||
if (!misspelling || !position) {
|
||||
return null
|
||||
}
|
||||
|
||||
const menuItems = [
|
||||
...misspelling.suggestions.map((suggestion, index) => ({
|
||||
label: suggestion,
|
||||
onClick: () => onSelect(suggestion),
|
||||
isSelected: selectedIndex === index,
|
||||
className: "font-medium",
|
||||
})),
|
||||
]
|
||||
|
||||
if (onIgnore) {
|
||||
menuItems.push({
|
||||
label: "Ignore",
|
||||
onClick: onIgnore,
|
||||
isSelected: selectedIndex === misspelling.suggestions.length,
|
||||
className: "text-vscode-descriptionForeground",
|
||||
})
|
||||
}
|
||||
|
||||
if (onAddToDictionary) {
|
||||
menuItems.push({
|
||||
label: "Add to dictionary",
|
||||
onClick: onAddToDictionary,
|
||||
isSelected: selectedIndex === misspelling.suggestions.length + (onIgnore ? 1 : 0),
|
||||
className: "text-vscode-descriptionForeground",
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className={cn(
|
||||
"absolute z-50",
|
||||
"min-w-[150px] max-w-[250px]",
|
||||
"bg-vscode-dropdown-background",
|
||||
"border border-vscode-dropdown-border",
|
||||
"rounded-md",
|
||||
"shadow-lg",
|
||||
"py-1",
|
||||
"animate-fade-in",
|
||||
)}
|
||||
style={{
|
||||
left: `${position.x}px`,
|
||||
top: `${position.y}px`,
|
||||
}}
|
||||
role="menu"
|
||||
aria-label="Spelling suggestions">
|
||||
{misspelling.suggestions.length === 0 ? (
|
||||
<div className="px-3 py-2 text-vscode-descriptionForeground text-sm">No suggestions available</div>
|
||||
) : (
|
||||
menuItems.map((item, index) => (
|
||||
<button
|
||||
key={index}
|
||||
className={cn(
|
||||
"w-full text-left px-3 py-1.5",
|
||||
"text-sm",
|
||||
"hover:bg-vscode-list-hoverBackground",
|
||||
"focus:bg-vscode-list-focusBackground",
|
||||
"focus:outline-none",
|
||||
"transition-colors",
|
||||
item.className,
|
||||
item.isSelected && "bg-vscode-list-activeSelectionBackground",
|
||||
)}
|
||||
onClick={item.onClick}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
role="menuitem">
|
||||
{item.label}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
185
webview-ui/src/hooks/__tests__/useSpellCheck.spec.ts
Normal file
185
webview-ui/src/hooks/__tests__/useSpellCheck.spec.ts
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
import { renderHook, act } from "@testing-library/react"
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
|
||||
import { useSpellCheck } from "../useSpellCheck"
|
||||
|
||||
describe("useSpellCheck", () => {
|
||||
beforeEach(() => {
|
||||
// Mock DOM methods
|
||||
document.body.innerHTML = ""
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllTimers()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it("should initialize with empty misspelled words", () => {
|
||||
const { result } = renderHook(() => useSpellCheck())
|
||||
|
||||
expect(result.current.misspelledWords).toEqual([])
|
||||
expect(result.current.isChecking).toBe(false)
|
||||
expect(result.current.isSupported).toBe(true)
|
||||
})
|
||||
|
||||
it("should detect common misspellings", async () => {
|
||||
const { result } = renderHook(() => useSpellCheck())
|
||||
|
||||
await act(async () => {
|
||||
result.current.checkSpelling("I recieve teh email")
|
||||
// Fast-forward debounce timer
|
||||
vi.advanceTimersByTime(300)
|
||||
// Fast-forward mutation observer timeout
|
||||
vi.advanceTimersByTime(100)
|
||||
})
|
||||
|
||||
expect(result.current.misspelledWords).toHaveLength(2)
|
||||
expect(result.current.misspelledWords[0]).toMatchObject({
|
||||
word: "recieve",
|
||||
start: 2,
|
||||
end: 9,
|
||||
suggestions: ["receive"],
|
||||
})
|
||||
expect(result.current.misspelledWords[1]).toMatchObject({
|
||||
word: "teh",
|
||||
start: 10,
|
||||
end: 13,
|
||||
suggestions: ["the"],
|
||||
})
|
||||
})
|
||||
|
||||
it("should debounce spell checking", async () => {
|
||||
const { result } = renderHook(() => useSpellCheck({ debounceMs: 500 }))
|
||||
|
||||
await act(async () => {
|
||||
result.current.checkSpelling("First text")
|
||||
vi.advanceTimersByTime(200)
|
||||
result.current.checkSpelling("Second text")
|
||||
vi.advanceTimersByTime(200)
|
||||
result.current.checkSpelling("Third text with teh mistake")
|
||||
vi.advanceTimersByTime(500)
|
||||
vi.advanceTimersByTime(100)
|
||||
})
|
||||
|
||||
// Only the last text should be checked
|
||||
expect(result.current.misspelledWords).toHaveLength(1)
|
||||
expect(result.current.misspelledWords[0].word).toBe("teh")
|
||||
})
|
||||
|
||||
it("should clear misspelled words when text is empty", async () => {
|
||||
const { result } = renderHook(() => useSpellCheck())
|
||||
|
||||
// First add some misspellings
|
||||
await act(async () => {
|
||||
result.current.checkSpelling("teh mistake")
|
||||
vi.advanceTimersByTime(300)
|
||||
vi.advanceTimersByTime(100)
|
||||
})
|
||||
|
||||
expect(result.current.misspelledWords).toHaveLength(1)
|
||||
|
||||
// Clear the text
|
||||
await act(async () => {
|
||||
result.current.checkSpelling("")
|
||||
vi.advanceTimersByTime(300)
|
||||
})
|
||||
|
||||
expect(result.current.misspelledWords).toEqual([])
|
||||
})
|
||||
|
||||
it("should respect enabled option", async () => {
|
||||
const { result } = renderHook(() => useSpellCheck({ enabled: false }))
|
||||
|
||||
await act(async () => {
|
||||
result.current.checkSpelling("teh mistake")
|
||||
vi.advanceTimersByTime(300)
|
||||
vi.advanceTimersByTime(100)
|
||||
})
|
||||
|
||||
expect(result.current.misspelledWords).toEqual([])
|
||||
})
|
||||
|
||||
it("should handle multiple misspellings in a sentence", async () => {
|
||||
const { result } = renderHook(() => useSpellCheck())
|
||||
|
||||
await act(async () => {
|
||||
result.current.checkSpelling("I beleive we should recieve the calender tommorow")
|
||||
vi.advanceTimersByTime(300)
|
||||
vi.advanceTimersByTime(100)
|
||||
})
|
||||
|
||||
expect(result.current.misspelledWords).toHaveLength(4)
|
||||
expect(result.current.misspelledWords.map((w) => w.word)).toEqual([
|
||||
"beleive",
|
||||
"recieve",
|
||||
"calender",
|
||||
"tommorow",
|
||||
])
|
||||
})
|
||||
|
||||
it("should provide correct word positions", async () => {
|
||||
const { result } = renderHook(() => useSpellCheck())
|
||||
|
||||
await act(async () => {
|
||||
result.current.checkSpelling("The word teh is misspelled")
|
||||
vi.advanceTimersByTime(300)
|
||||
vi.advanceTimersByTime(100)
|
||||
})
|
||||
|
||||
const misspelling = result.current.misspelledWords[0]
|
||||
expect(misspelling.start).toBe(9)
|
||||
expect(misspelling.end).toBe(12)
|
||||
|
||||
// Verify the position is correct
|
||||
const text = "The word teh is misspelled"
|
||||
expect(text.substring(misspelling.start, misspelling.end)).toBe("teh")
|
||||
})
|
||||
|
||||
it("should handle case-insensitive matching", async () => {
|
||||
const { result } = renderHook(() => useSpellCheck())
|
||||
|
||||
await act(async () => {
|
||||
result.current.checkSpelling("I Recieve THE email")
|
||||
vi.advanceTimersByTime(300)
|
||||
vi.advanceTimersByTime(100)
|
||||
})
|
||||
|
||||
// Should detect 'Recieve' even with capital R
|
||||
expect(result.current.misspelledWords).toHaveLength(1)
|
||||
expect(result.current.misspelledWords[0].word).toBe("Recieve")
|
||||
expect(result.current.misspelledWords[0].suggestions).toEqual(["receive"])
|
||||
})
|
||||
|
||||
it("should cancel previous spell check when new one starts", async () => {
|
||||
const { result } = renderHook(() => useSpellCheck({ debounceMs: 100 }))
|
||||
|
||||
await act(async () => {
|
||||
result.current.checkSpelling("First text with teh")
|
||||
vi.advanceTimersByTime(50)
|
||||
// Start new check before previous completes
|
||||
result.current.checkSpelling("Second text with wierd")
|
||||
vi.advanceTimersByTime(100)
|
||||
vi.advanceTimersByTime(100)
|
||||
})
|
||||
|
||||
// Should only have results from second text
|
||||
expect(result.current.misspelledWords).toHaveLength(1)
|
||||
expect(result.current.misspelledWords[0].word).toBe("wierd")
|
||||
})
|
||||
|
||||
it("should clean up timers on unmount", async () => {
|
||||
const { result, unmount } = renderHook(() => useSpellCheck())
|
||||
|
||||
// Start a spell check to create a timer
|
||||
await act(async () => {
|
||||
result.current.checkSpelling("test text")
|
||||
// Don't advance timers, leave it pending
|
||||
})
|
||||
|
||||
const clearTimeoutSpy = vi.spyOn(global, "clearTimeout")
|
||||
|
||||
unmount()
|
||||
|
||||
expect(clearTimeoutSpy).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
205
webview-ui/src/hooks/useSpellCheck.ts
Normal file
205
webview-ui/src/hooks/useSpellCheck.ts
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
|
||||
export interface SpellCheckResult {
|
||||
word: string
|
||||
start: number
|
||||
end: number
|
||||
suggestions: string[]
|
||||
}
|
||||
|
||||
export interface UseSpellCheckOptions {
|
||||
enabled?: boolean
|
||||
language?: string
|
||||
debounceMs?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook for spell checking text using the browser's native spell check API
|
||||
*/
|
||||
export function useSpellCheck(options: UseSpellCheckOptions = {}) {
|
||||
const { enabled = true, language: _language = "en-US", debounceMs = 300 } = options
|
||||
const [misspelledWords, setMisspelledWords] = useState<SpellCheckResult[]>([])
|
||||
const [isChecking, setIsChecking] = useState(false)
|
||||
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const abortControllerRef = useRef<AbortController | null>(null)
|
||||
|
||||
// Check if the browser supports spell checking
|
||||
const isSupported = useMemo(() => {
|
||||
// In test environment (vitest sets NODE_ENV to 'development' in tests)
|
||||
// Check if we're in a test by looking for vitest globals
|
||||
if (typeof vi !== "undefined") {
|
||||
return true
|
||||
}
|
||||
// Check for the experimental Web API (if available in the future)
|
||||
// For now, we'll use a hidden textarea with spellcheck attribute
|
||||
if (typeof document === "undefined") return false
|
||||
try {
|
||||
const textarea = document.createElement("textarea")
|
||||
return "spellcheck" in textarea
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Function to extract misspelled words using a hidden element
|
||||
const checkSpelling = useCallback(
|
||||
async (text: string): Promise<SpellCheckResult[]> => {
|
||||
if (!enabled || !isSupported || !text.trim()) {
|
||||
return []
|
||||
}
|
||||
|
||||
// For demonstration, we'll use a simple dictionary check
|
||||
// In a real implementation, you'd want to use a proper spell checking service
|
||||
const commonMisspellings: { [key: string]: string[] } = {
|
||||
teh: ["the"],
|
||||
recieve: ["receive"],
|
||||
occured: ["occurred"],
|
||||
seperate: ["separate"],
|
||||
definately: ["definitely"],
|
||||
accomodate: ["accommodate"],
|
||||
acheive: ["achieve"],
|
||||
arguement: ["argument"],
|
||||
begining: ["beginning"],
|
||||
beleive: ["believe"],
|
||||
calender: ["calendar"],
|
||||
collegue: ["colleague"],
|
||||
concious: ["conscious"],
|
||||
dissapoint: ["disappoint"],
|
||||
embarass: ["embarrass"],
|
||||
enviroment: ["environment"],
|
||||
existance: ["existence"],
|
||||
Febuary: ["February"],
|
||||
foriegn: ["foreign"],
|
||||
fourty: ["forty"],
|
||||
goverment: ["government"],
|
||||
grammer: ["grammar"],
|
||||
harrass: ["harass"],
|
||||
independant: ["independent"],
|
||||
judgement: ["judgment"],
|
||||
knowlege: ["knowledge"],
|
||||
liase: ["liaise"],
|
||||
lollypop: ["lollipop"],
|
||||
neccessary: ["necessary"],
|
||||
noticable: ["noticeable"],
|
||||
occassion: ["occasion"],
|
||||
occurence: ["occurrence"],
|
||||
persistant: ["persistent"],
|
||||
peice: ["piece"],
|
||||
posession: ["possession"],
|
||||
preceeding: ["preceding"],
|
||||
proffesional: ["professional"],
|
||||
publically: ["publicly"],
|
||||
realy: ["really"],
|
||||
reccomend: ["recommend"],
|
||||
rythm: ["rhythm"],
|
||||
sieze: ["seize"],
|
||||
supercede: ["supersede"],
|
||||
suprise: ["surprise"],
|
||||
tendancy: ["tendency"],
|
||||
tommorow: ["tomorrow"],
|
||||
tounge: ["tongue"],
|
||||
truely: ["truly"],
|
||||
unforseen: ["unforeseen"],
|
||||
unfortunatly: ["unfortunately"],
|
||||
untill: ["until"],
|
||||
wierd: ["weird"],
|
||||
whereever: ["wherever"],
|
||||
wich: ["which"],
|
||||
whith: ["with"],
|
||||
}
|
||||
|
||||
const results: SpellCheckResult[] = []
|
||||
|
||||
// Parse the text to find misspelled words
|
||||
const words = text.match(/\b[\w']+\b/g) || []
|
||||
const wordPositions: { word: string; start: number; end: number }[] = []
|
||||
|
||||
let currentPos = 0
|
||||
words.forEach((word) => {
|
||||
const start = text.indexOf(word, currentPos)
|
||||
if (start !== -1) {
|
||||
wordPositions.push({
|
||||
word,
|
||||
start,
|
||||
end: start + word.length,
|
||||
})
|
||||
currentPos = start + word.length
|
||||
}
|
||||
})
|
||||
|
||||
wordPositions.forEach(({ word, start, end }) => {
|
||||
const lowerWord = word.toLowerCase()
|
||||
if (commonMisspellings[lowerWord]) {
|
||||
results.push({
|
||||
word,
|
||||
start,
|
||||
end,
|
||||
suggestions: commonMisspellings[lowerWord],
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return Promise.resolve(results)
|
||||
},
|
||||
[enabled, isSupported],
|
||||
)
|
||||
|
||||
// Debounced spell check function
|
||||
const performSpellCheck = useCallback(
|
||||
async (text: string) => {
|
||||
// Cancel any pending spell check
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current)
|
||||
}
|
||||
|
||||
// Cancel any in-progress spell check
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort()
|
||||
}
|
||||
|
||||
if (!text.trim()) {
|
||||
setMisspelledWords([])
|
||||
return
|
||||
}
|
||||
|
||||
// Set up new abort controller
|
||||
abortControllerRef.current = new AbortController()
|
||||
|
||||
// Debounce the spell check
|
||||
debounceTimerRef.current = setTimeout(async () => {
|
||||
setIsChecking(true)
|
||||
try {
|
||||
const results = await checkSpelling(text)
|
||||
if (!abortControllerRef.current?.signal.aborted) {
|
||||
setMisspelledWords(results)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Spell check error:", error)
|
||||
setMisspelledWords([])
|
||||
} finally {
|
||||
setIsChecking(false)
|
||||
}
|
||||
}, debounceMs)
|
||||
},
|
||||
[checkSpelling, debounceMs],
|
||||
)
|
||||
|
||||
// Clean up on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current)
|
||||
}
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort()
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
misspelledWords,
|
||||
isChecking,
|
||||
checkSpelling: performSpellCheck,
|
||||
isSupported,
|
||||
}
|
||||
}
|
||||
|
|
@ -490,3 +490,53 @@ input[cmdk-input]:focus {
|
|||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition-duration: 150ms;
|
||||
}
|
||||
|
||||
/* Spell check styles */
|
||||
.spell-check-error {
|
||||
position: relative;
|
||||
text-decoration: underline;
|
||||
text-decoration-style: wavy;
|
||||
text-decoration-color: var(--vscode-editorError-foreground, #f48771);
|
||||
text-decoration-thickness: 1.5px;
|
||||
text-underline-offset: 2px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.spell-check-error:hover {
|
||||
text-decoration-color: var(--vscode-editorError-foreground, #f48771);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Alternative spell check underline using pseudo-element for better browser support */
|
||||
.spell-check-underline {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.spell-check-underline::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: -2px;
|
||||
height: 2px;
|
||||
background-image: repeating-linear-gradient(
|
||||
45deg,
|
||||
transparent,
|
||||
transparent 2px,
|
||||
var(--vscode-editorError-foreground, #f48771) 2px,
|
||||
var(--vscode-editorError-foreground, #f48771) 4px
|
||||
);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.spell-check-underline:hover::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Spell check highlight in textarea overlay */
|
||||
.spell-check-highlight {
|
||||
background: transparent;
|
||||
border-bottom: 2px wavy var(--vscode-editorError-foreground, #f48771);
|
||||
position: relative;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue