Add spell checking with hover tooltips to RichChatInput

- Implement inline spell checking with pattern-based detection for keyboard mashing, excessive consonants, and very long words
- Add hover tooltips positioned 1px above misspelled words with suggestions
- Include tooltip hover persistence to allow user interaction
- Add visual highlighting with bright red background for clear visibility
- Use textarea-based hover detection for cross-platform compatibility
- Integrate spell checking into dual-layer rich text input architecture
This commit is contained in:
spencrmartin
2025-09-25 10:41:57 -04:00
parent 538881d6af
commit 80cf2f10ee
2 changed files with 103 additions and 7 deletions
+83 -5
View File
@@ -113,6 +113,7 @@ export const RichChatInput = forwardRef<RichChatInputRef, RichChatInputProps>(({
suggestions: string[];
wordStart: number;
wordEnd: number;
isHoveringTooltip: boolean;
}>({
isVisible: false,
position: { x: 0, y: 0 },
@@ -120,6 +121,7 @@ export const RichChatInput = forwardRef<RichChatInputRef, RichChatInputProps>(({
suggestions: [],
wordStart: 0,
wordEnd: 0,
isHoveringTooltip: false,
});
// Expose methods to parent component
@@ -245,7 +247,28 @@ export const RichChatInput = forwardRef<RichChatInputRef, RichChatInputProps>(({
}
}, 500); // Debounce spell check by 500ms
return () => clearTimeout(timeoutId);
const handleTooltipEnter = () => {
console.log('🖱️ TOOLTIP ENTER: Setting isHoveringTooltip to true');
setTooltipState(prev => ({
...prev,
isHoveringTooltip: true,
}));
};
const handleTooltipLeave = () => {
console.log('🖱️ TOOLTIP LEAVE: Setting isHoveringTooltip to false and hiding tooltip');
setTooltipState(prev => ({
...prev,
isHoveringTooltip: false,
isVisible: false,
suggestions: [],
misspelledWord: '',
wordIndex: -1,
}));
};
return () => clearTimeout(timeoutId);
}, [value, performSpellCheck]);
// Parse and render content with action pills, mention pills, spell checking, and cursor
@@ -489,10 +512,16 @@ export const RichChatInput = forwardRef<RichChatInputRef, RichChatInputProps>(({
}}
onMouseLeave={(e) => {
console.log('🖱️ MOUSELEAVE: Mouse left misspelled word:', content);
// Add a small delay before hiding to allow clicking on tooltip
// Add a small delay before hiding to allow moving to tooltip
setTimeout(() => {
setTooltip(prev => ({ ...prev, isVisible: false }));
}, 150);
setTooltip(prev => {
// Only hide if not hovering over the tooltip
if (!prev.isHoveringTooltip) {
return { ...prev, isVisible: false };
}
return prev;
});
}, 100);
}}
>
{content}
@@ -565,6 +594,9 @@ export const RichChatInput = forwardRef<RichChatInputRef, RichChatInputProps>(({
}, [onChange]);
const handleTextareaKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
// Hide tooltip on any key press
setTooltip(prev => ({ ...prev, isVisible: false }));
// Update cursor position on key events
setTimeout(updateCursorPosition, 0);
@@ -649,6 +681,8 @@ export const RichChatInput = forwardRef<RichChatInputRef, RichChatInputProps>(({
const handleTextareaBlur = useCallback(() => {
setIsFocused(false);
// Hide tooltip when input loses focus
setTooltip(prev => ({ ...prev, isVisible: false }));
onBlur?.();
}, [onBlur]);
@@ -695,8 +729,50 @@ export const RichChatInput = forwardRef<RichChatInputRef, RichChatInputProps>(({
setTooltip(prev => ({ ...prev, isVisible: false }));
}, [tooltip.misspelledWord]);
// Container mouse leave handler
const handleContainerMouseLeave = useCallback(() => {
console.log('🖱️ CONTAINER MOUSE LEAVE: Hiding tooltip');
setTooltip(prev => ({ ...prev, isVisible: false }));
}, []);
// Hide tooltip when clicking outside or when component loses focus
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (displayRef.current && !displayRef.current.contains(event.target as Node)) {
setTooltip(prev => ({ ...prev, isVisible: false }));
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, []);
// Tooltip hover handlers
const handleTooltipEnter = useCallback(() => {
console.log('🖱️ TOOLTIP ENTER: Setting isHoveringTooltip to true');
setTooltip(prev => ({
...prev,
isHoveringTooltip: true,
}));
}, []);
const handleTooltipLeave = useCallback(() => {
console.log('🖱️ TOOLTIP LEAVE: Setting isHoveringTooltip to false and hiding tooltip');
setTooltip(prev => ({
...prev,
isHoveringTooltip: false,
isVisible: false,
}));
}, []);
return (
<div className="relative rich-text-input">
<div
className="relative rich-text-input"
onMouseLeave={handleContainerMouseLeave}
>
{/* Hidden textarea for actual input handling with spell check enabled */}
<textarea
ref={hiddenTextareaRef}
@@ -857,6 +933,8 @@ export const RichChatInput = forwardRef<RichChatInputRef, RichChatInputProps>(({
onSuggestionSelect={handleSuggestionSelect}
onAddToDictionary={handleAddToDictionary}
onIgnore={handleIgnore}
onMouseEnter={handleTooltipEnter}
onMouseLeave={handleTooltipLeave}
/>
</div>
);
@@ -8,6 +8,8 @@ interface SpellCheckTooltipProps {
onSuggestionSelect: (suggestion: string) => void;
onAddToDictionary: () => void;
onIgnore: () => void;
onMouseEnter?: () => void;
onMouseLeave?: () => void;
}
export const SpellCheckTooltip: React.FC<SpellCheckTooltipProps> = ({
@@ -18,10 +20,24 @@ export const SpellCheckTooltip: React.FC<SpellCheckTooltipProps> = ({
onSuggestionSelect,
onAddToDictionary,
onIgnore,
onMouseEnter,
onMouseLeave,
}) => {
const tooltipRef = useRef<HTMLDivElement>(null);
if (!isVisible) return null;
console.log('🖱️ TOOLTIP COMPONENT: Rendering with props:', {
isVisible,
position,
suggestions,
misspelledWord
});
if (!isVisible) {
console.log('🖱️ TOOLTIP COMPONENT: Not visible, returning null');
return null;
}
console.log('🖱️ TOOLTIP COMPONENT: Rendering visible tooltip');
return (
<div
@@ -29,9 +45,11 @@ export const SpellCheckTooltip: React.FC<SpellCheckTooltipProps> = ({
className="fixed z-50 bg-background-default border border-borderStandard rounded-lg shadow-lg py-2 min-w-48 max-w-64"
style={{
left: position.x,
top: position.y - 10,
top: position.y - 1, // Only 1px from the top of the misspelled word
transform: 'translateY(-100%)', // Position above the word
}}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
{/* Header */}
<div className="px-3 py-1 text-xs text-text-muted border-b border-borderSubtle mb-1">