Remove custom spell checking implementation in favor of native OS spell checking

- Remove custom spell checking logic from RichChatInput component
- Remove SpellCheckTooltip and SpellCheckContextMenu components
- Remove IPC spell-check and spell-suggestions handlers from main process
- Enable native browser spell checking with spellCheck={true} on textarea
- Simplify component by removing misspelled word highlighting and tooltip state
- Users now get familiar OS-native spell check with right-click context menus

This provides better UX with system dictionary integration and familiar behavior.
This commit is contained in:
spencrmartin
2025-09-25 16:04:49 -04:00
committed by Alex Hancock
parent f809bb689c
commit de52931e17
4 changed files with 10 additions and 1059 deletions
+10 -385
View File
@@ -1,6 +1,4 @@
import React, { useRef, useEffect, useState, useCallback, forwardRef, useImperativeHandle } from 'react';
import SpellCheckTooltip from './SpellCheckTooltip';
// Remove unused import - using Electron spell checking instead
import { ActionPill } from './ActionPill';
import MentionPill from './MentionPill';
import { Zap, Code, FileText, Search, Play, Settings } from 'lucide-react';
@@ -40,69 +38,6 @@ export interface RichChatInputRef {
getBoundingClientRect: () => DOMRect;
}
// Use Electron's system spell checking
const checkSpelling = async (text: string): Promise<{ word: string; start: number; end: number; suggestions: string[] }[]> => {
console.log('🔍 ELECTRON SPELL CHECK: Starting system spell check for text:', text);
const misspelledWords: { word: string; start: number; end: number; suggestions: string[] }[] = [];
// Check if Electron API is available
if (!window.electron?.spellCheck || !window.electron?.spellSuggestions) {
console.warn('🔍 ELECTRON SPELL CHECK: Electron spell check API not available, falling back to no spell checking');
return misspelledWords;
}
// Split text into words while preserving positions
const wordRegex = /\b[a-zA-Z]+\b/g;
let match;
const wordChecks: Array<{word: string; start: number; end: number}> = [];
// First, collect all words and their positions
while ((match = wordRegex.exec(text)) !== null) {
const word = match[0];
const start = match.index;
const end = start + word.length;
// Skip very short words (less than 3 characters)
if (word.length < 3) {
continue;
}
wordChecks.push({ word, start, end });
}
console.log('🔍 ELECTRON SPELL CHECK: Found words to check:', wordChecks.map(w => w.word));
// Check each word and collect results
for (const { word, start, end } of wordChecks) {
try {
const isCorrect = await window.electron.spellCheck(word);
console.log('🔍 ELECTRON SPELL CHECK: Word:', word, 'isCorrect:', isCorrect);
if (!isCorrect) {
// Get suggestions from Electron
const suggestions = await window.electron.spellSuggestions(word);
console.log('🔍 ELECTRON SPELL CHECK: Suggestions for', word, ':', suggestions);
misspelledWords.push({
word: word,
start: start,
end: end,
suggestions: suggestions || []
});
}
} catch (error) {
console.error('🔍 ELECTRON SPELL CHECK: Error checking word', word, ':', error);
}
}
// Sort misspelled words by position
misspelledWords.sort((a, b) => a.start - b.start);
console.log('🔍 ELECTRON SPELL CHECK: Final result:', misspelledWords);
return misspelledWords;
};
export const RichChatInput = forwardRef<RichChatInputRef, RichChatInputProps>(({
value,
onChange,
@@ -124,7 +59,6 @@ export const RichChatInput = forwardRef<RichChatInputRef, RichChatInputProps>(({
const displayRef = useRef<HTMLDivElement>(null);
const [isFocused, setIsFocused] = useState(false);
const [cursorPosition, setCursorPosition] = useState(0);
const [misspelledWords, setMisspelledWords] = useState<{ word: string; start: number; end: number; suggestions: string[] }[]>([]);
// Scroll synchronization - ensure both layers stay perfectly in sync
const handleTextareaScroll = useCallback(() => {
@@ -237,27 +171,8 @@ export const RichChatInput = forwardRef<RichChatInputRef, RichChatInputProps>(({
resizeObserver.disconnect();
};
}
}, [syncDisplayHeight]);
}, [monitorTextareaChanges]);
// Spell check tooltip state
const [tooltip, setTooltip] = useState<{
isVisible: boolean;
position: { x: number; y: number };
misspelledWord: string;
suggestions: string[];
wordStart: number;
wordEnd: number;
isHoveringTooltip: boolean;
}>({
isVisible: false,
position: { x: 0, y: 0 },
misspelledWord: '',
suggestions: [],
wordStart: 0,
wordEnd: 0,
isHoveringTooltip: false,
});
// Expose methods to parent component
useImperativeHandle(ref, () => ({
focus: () => hiddenTextareaRef.current?.focus(),
@@ -276,89 +191,9 @@ export const RichChatInput = forwardRef<RichChatInputRef, RichChatInputProps>(({
if (hiddenTextareaRef.current) {
setCursorPosition(hiddenTextareaRef.current.selectionStart);
}
}, []);
}, [updateCursorPosition]);
// Track the last spell checked text to avoid unnecessary re-checks
const lastSpellCheckedTextRef = useRef<string>('');
const spellCheckTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Spell check the content using Electron's system spell checker
const performSpellCheck = useCallback(async (text: string, isIncremental = false) => {
console.log('🔍 ELECTRON SPELL CHECK: Starting system spell check for text:', text, 'incremental:', isIncremental);
// Skip if we've already checked this exact text
if (text === lastSpellCheckedTextRef.current) {
console.log('🔍 ELECTRON SPELL CHECK: Skipping - text unchanged');
return;
}
// Use the Electron spell checking function
try {
const misspelledWords = await checkSpelling(text);
console.log('🔍 ELECTRON SPELL CHECK: System spell check result:', misspelledWords);
// Critical: Check current value at time of update, not captured value
const currentValue = hiddenTextareaRef.current?.value || '';
// Only update if the text hasn't changed since we started checking
if (text === currentValue) {
// Use functional update to avoid dependency on value in useCallback
setMisspelledWords(misspelledWords);
lastSpellCheckedTextRef.current = text;
} else {
console.log('🔍 ELECTRON SPELL CHECK: Discarding stale result - text changed during check');
}
} catch (error) {
console.error('🔍 ELECTRON SPELL CHECK: Error performing spell check:', error);
// Fallback to no spell checking on error
const currentValue = hiddenTextareaRef.current?.value || '';
if (text === currentValue) {
setMisspelledWords([]);
}
}
}, []); // Remove value dependency to prevent recreation
// Smart spell check timing - check after word completion and with shorter delays
useEffect(() => {
// Clear any existing timeout
if (spellCheckTimeoutRef.current) {
clearTimeout(spellCheckTimeoutRef.current);
}
if (!value.trim()) {
setMisspelledWords([]);
lastSpellCheckedTextRef.current = '';
return;
}
// Detect if user just completed a word (typed space, punctuation, or newline)
const lastChar = value[value.length - 1];
const isWordBoundary = /[\s\n\.,!?;:]/.test(lastChar);
// Use different delays based on context
let delay: number;
if (isWordBoundary) {
// Just completed a word - check quickly
delay = 150;
} else {
// Still typing within a word - wait longer to avoid interrupting
delay = 300;
}
console.log('🔍 SPELL CHECK TIMING: Setting timeout with delay:', delay, 'isWordBoundary:', isWordBoundary, 'lastChar:', lastChar);
spellCheckTimeoutRef.current = setTimeout(() => {
performSpellCheck(value, !isWordBoundary);
}, delay);
return () => {
if (spellCheckTimeoutRef.current) {
clearTimeout(spellCheckTimeoutRef.current);
}
};
}, [value, performSpellCheck]);
// Parse and render content with action pills, mention pills, spell checking, and cursor
// Parse and render content with action pills, mention pills, and cursor
const renderContent = useCallback(() => {
// Show placeholder when there's no text content (but account for whitespace-only content with newlines)
if (!value || (value.trim() === '' && !value.includes('\n'))) {
@@ -395,9 +230,8 @@ export const RichChatInput = forwardRef<RichChatInputRef, RichChatInputProps>(({
console.log('🎨 RichChatInput renderContent called with value:', value);
console.log('🔍 Looking for action and mention patterns with regex:', { actionRegex, mentionRegex });
console.log('📝 Misspelled words:', misspelledWords);
// Find all actions, mentions, and misspelled words, then sort by position
// Find all actions and mentions, then sort by position
const allMatches = [];
// Find all action matches
@@ -428,17 +262,6 @@ export const RichChatInput = forwardRef<RichChatInputRef, RichChatInputProps>(({
});
}
// Add misspelled words
misspelledWords.forEach(misspelled => {
allMatches.push({
type: 'misspelled',
match: null,
index: misspelled.start,
length: misspelled.end - misspelled.start,
content: misspelled.word
});
});
// Sort matches by position
allMatches.sort((a, b) => a.index - b.index);
@@ -449,7 +272,7 @@ export const RichChatInput = forwardRef<RichChatInputRef, RichChatInputProps>(({
let lastProcessedEnd = 0;
for (const matchData of allMatches) {
// Skip overlapping matches (pills take priority over spell check)
// Skip overlapping matches
if (matchData.index < lastProcessedEnd) {
continue;
}
@@ -541,102 +364,6 @@ export const RichChatInput = forwardRef<RichChatInputRef, RichChatInputProps>(({
onRemove={() => handleRemoveMention(fileName)}
/>
);
} else if (type === 'misspelled') {
// Handle misspelled words with red highlighting and hover tooltip
const misspelledData = misspelledWords.find(m => m.word === content);
console.log('🎨 RENDERING MISSPELLED: word:', content, 'data:', misspelledData);
console.log('🎨 RENDERING MISSPELLED: all misspelled words:', misspelledWords);
parts.push(
<span
key={`misspelled-${keyCounter++}`}
data-misspelled="true"
className="inline whitespace-pre-wrap cursor-pointer bg-red-50 dark:bg-red-950/30 text-red-600 dark:text-red-400 font-medium px-1 py-0.5 rounded-sm border border-red-200 dark:border-red-800 hover:bg-red-100 dark:hover:bg-red-900/40 hover:border-red-300 dark:hover:border-red-700 hover:scale-105 transition-all duration-150 relative z-50"
style={{
pointerEvents: 'auto', // Override parent's pointer-events: none
userSelect: 'text', // Allow text selection for normal text editing
}}
title={`Click or hover for suggestions: ${content}`}
onClick={(e) => {
console.log('🖱️ CLICK: Clicked on misspelled word:', content);
e.preventDefault();
e.stopPropagation();
// Ensure textarea maintains focus after click
setTimeout(() => {
if (hiddenTextareaRef.current) {
hiddenTextareaRef.current.focus();
}
}, 0);
if (misspelledData) {
const rect = e.currentTarget.getBoundingClientRect();
console.log('🖱️ CLICK: Element rect:', rect);
// Show tooltip on click - positioned at center of word
const tooltipData = {
isVisible: true,
position: {
x: rect.left + rect.width / 2,
y: rect.top
},
misspelledWord: misspelledData.word,
suggestions: misspelledData.suggestions || [],
wordStart: misspelledData.start,
wordEnd: misspelledData.end,
isHoveringTooltip: false,
};
console.log('🖱️ CLICK: Setting tooltip data:', tooltipData);
setTooltip(tooltipData);
}
}}
onMouseEnter={(e) => {
console.log('🖱️ MOUSEENTER: Mouse entered misspelled word:', content);
// Only show tooltip on hover if we're not actively typing
// Check if the textarea has been recently focused/active
const now = Date.now();
const lastActivity = hiddenTextareaRef.current?.dataset.lastActivity;
const timeSinceActivity = lastActivity ? now - parseInt(lastActivity) : Infinity;
// Only show hover tooltip if it's been more than 500ms since last typing activity
if (timeSinceActivity > 500 && misspelledData) {
const rect = e.currentTarget.getBoundingClientRect();
console.log('🖱️ MOUSEENTER: Element rect:', rect);
const tooltipData = {
isVisible: true,
position: {
x: rect.left + rect.width / 2,
y: rect.top
},
misspelledWord: misspelledData.word,
suggestions: misspelledData.suggestions || [],
wordStart: misspelledData.start,
wordEnd: misspelledData.end,
isHoveringTooltip: false,
};
console.log('🖱️ MOUSEENTER: Setting tooltip data:', tooltipData);
setTooltip(tooltipData);
}
}}
onMouseLeave={(e) => {
console.log('🖱️ MOUSELEAVE: Mouse left misspelled word:', content);
// Add a small delay before hiding to allow moving to tooltip
setTimeout(() => {
setTooltip(prev => {
// Only hide if not hovering over the tooltip
if (!prev.isHoveringTooltip) {
return { ...prev, isVisible: false };
}
return prev;
});
}, 150);
}}
>
{content}
</span>
);
}
currentPos += length;
@@ -694,7 +421,7 @@ export const RichChatInput = forwardRef<RichChatInputRef, RichChatInputProps>(({
)}
</div>
);
}, [value, isFocused, placeholder, cursorPosition, misspelledWords]);
}, [value, isFocused, placeholder, cursorPosition]);
const handleRemoveAction = useCallback((actionLabel: string) => {
const actionText = `[${actionLabel}]`;
@@ -712,11 +439,6 @@ export const RichChatInput = forwardRef<RichChatInputRef, RichChatInputProps>(({
const newValue = e.target.value;
const newCursorPos = e.target.selectionStart;
// Track typing activity to prevent hover tooltips while actively typing
if (hiddenTextareaRef.current) {
hiddenTextareaRef.current.dataset.lastActivity = Date.now().toString();
}
console.log('🔄 RichChatInput: onChange', { newValue, newCursorPos });
onChange(newValue, newCursorPos);
setCursorPosition(newCursorPos);
@@ -728,9 +450,6 @@ export const RichChatInput = forwardRef<RichChatInputRef, RichChatInputProps>(({
}, [onChange, syncDisplayHeight]);
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);
@@ -815,8 +534,6 @@ export const RichChatInput = forwardRef<RichChatInputRef, RichChatInputProps>(({
const handleTextareaBlur = useCallback(() => {
setIsFocused(false);
// Hide tooltip when input loses focus
setTooltip(prev => ({ ...prev, isVisible: false }));
onBlur?.();
}, [onBlur]);
@@ -848,87 +565,9 @@ export const RichChatInput = forwardRef<RichChatInputRef, RichChatInputProps>(({
return cleanup;
}, [monitorTextareaChanges]);
// Tooltip handlers
const handleSuggestionSelect = useCallback((suggestion: string) => {
const newValue = value.slice(0, tooltip.wordStart) +
suggestion +
value.slice(tooltip.wordEnd);
onChange(newValue);
setTooltip(prev => ({ ...prev, isVisible: false }));
}, [value, onChange, tooltip.wordStart, tooltip.wordEnd]);
const handleAddToDictionary = useCallback(() => {
// TODO: Implement add to dictionary functionality
console.log('Add to dictionary:', tooltip.misspelledWord);
setTooltip(prev => ({ ...prev, isVisible: false }));
}, [tooltip.misspelledWord]);
const handleIgnore = useCallback(() => {
// TODO: Implement ignore functionality
console.log('Ignore word:', tooltip.misspelledWord);
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) => {
const target = event.target as Node;
// Don't hide if clicking on the tooltip itself or its children
const tooltipElement = document.querySelector('[data-spell-tooltip="true"]');
if (tooltipElement && tooltipElement.contains(target)) {
return;
}
// Don't hide if clicking on a misspelled word
const misspelledElement = target as Element;
if (misspelledElement?.closest?.('[data-misspelled="true"]')) {
return;
}
// Hide tooltip if clicking outside the input area
if (displayRef.current && !displayRef.current.contains(target)) {
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"
onMouseLeave={handleContainerMouseLeave}
>
{/* Hidden textarea for actual input handling with spell check enabled */}
<div className="relative rich-text-input">
{/* Hidden textarea for actual input handling with native spell check enabled */}
<textarea
ref={hiddenTextareaRef}
value={value}
@@ -941,7 +580,7 @@ export const RichChatInput = forwardRef<RichChatInputRef, RichChatInputProps>(({
onCompositionEnd={onCompositionEnd}
disabled={disabled}
data-testid={testId}
spellCheck={false} // Disable browser spell check - we handle it ourselves
spellCheck={true} // Enable native OS spell checking
className="absolute inset-0 w-full resize-none overflow-y-auto"
onScroll={handleTextareaScroll}
style={{
@@ -972,7 +611,7 @@ export const RichChatInput = forwardRef<RichChatInputRef, RichChatInputProps>(({
rows={rows}
/>
{/* Visual display with action pills, mention pills, spell check, and cursor */}
{/* Visual display with action pills, mention pills, and cursor */}
<div
ref={displayRef}
className={`${className} cursor-text relative overflow-y-auto rich-text-display`}
@@ -1029,20 +668,6 @@ export const RichChatInput = forwardRef<RichChatInputRef, RichChatInputProps>(({
}
`
}} />
{/* Spell Check Hover Tooltip */}
{console.log('🖱️ TOOLTIP RENDER: tooltip state:', tooltip)}
<SpellCheckTooltip
isVisible={tooltip.isVisible}
position={tooltip.position}
suggestions={tooltip.suggestions}
misspelledWord={tooltip.misspelledWord}
onSuggestionSelect={handleSuggestionSelect}
onAddToDictionary={handleAddToDictionary}
onIgnore={handleIgnore}
onMouseEnter={handleTooltipEnter}
onMouseLeave={handleTooltipLeave}
/>
</div>
);
});
@@ -1,120 +0,0 @@
import React, { useEffect, useRef } from 'react';
interface SpellCheckContextMenuProps {
isOpen: boolean;
position: { x: number; y: number };
suggestions: string[];
misspelledWord: string;
onSuggestionSelect: (suggestion: string) => void;
onAddToDictionary: () => void;
onIgnore: () => void;
onClose: () => void;
}
export const SpellCheckContextMenu: React.FC<SpellCheckContextMenuProps> = ({
isOpen,
position,
suggestions,
misspelledWord,
onSuggestionSelect,
onAddToDictionary,
onIgnore,
onClose,
}) => {
const menuRef = useRef<HTMLDivElement>(null);
// Close menu when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
onClose();
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
// Prevent the default context menu
document.addEventListener('contextmenu', (e) => e.preventDefault());
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('contextmenu', (e) => e.preventDefault());
};
}, [isOpen, onClose]);
// Close menu on escape key
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose();
}
};
if (isOpen) {
document.addEventListener('keydown', handleKeyDown);
}
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
<div
ref={menuRef}
className="fixed z-50 bg-background-default border border-borderStandard rounded-lg shadow-lg py-2 min-w-48"
style={{
left: position.x,
top: position.y,
}}
>
{/* Misspelled word header */}
<div className="px-3 py-1 text-xs text-text-muted border-b border-borderSubtle mb-1">
Suggestions for "{misspelledWord}"
</div>
{/* Suggestions */}
{suggestions.length > 0 ? (
suggestions.map((suggestion, index) => (
<button
key={index}
onClick={() => onSuggestionSelect(suggestion)}
className="w-full text-left px-3 py-2 text-sm text-text-default hover:bg-bgSubtle transition-colors flex items-center gap-2"
>
<span className="w-4 h-4 flex items-center justify-center text-xs bg-blue-500 text-white rounded">
{index + 1}
</span>
<span className="font-medium">{suggestion}</span>
</button>
))
) : (
<div className="px-3 py-2 text-sm text-text-muted italic">
No suggestions available
</div>
)}
{/* Separator */}
<div className="border-t border-borderSubtle my-1" />
{/* Additional actions */}
<button
onClick={onAddToDictionary}
className="w-full text-left px-3 py-2 text-sm text-text-default hover:bg-bgSubtle transition-colors"
>
Add "{misspelledWord}" to dictionary
</button>
<button
onClick={onIgnore}
className="w-full text-left px-3 py-2 text-sm text-text-default hover:bg-bgSubtle transition-colors"
>
Ignore "{misspelledWord}"
</button>
</div>
);
};
export default SpellCheckContextMenu;
@@ -1,263 +0,0 @@
import React, { useState, useRef, useEffect } from 'react';
interface SpellCheckTooltipProps {
isVisible: boolean;
position: { x: number; y: number };
suggestions: string[];
misspelledWord: string;
onSuggestionSelect: (suggestion: string) => void;
onAddToDictionary: () => void;
onIgnore: () => void;
onMouseEnter?: () => void;
onMouseLeave?: () => void;
}
export const SpellCheckTooltip: React.FC<SpellCheckTooltipProps> = ({
isVisible,
position,
suggestions,
misspelledWord,
onSuggestionSelect,
onAddToDictionary,
onIgnore,
onMouseEnter,
onMouseLeave,
}) => {
const tooltipRef = useRef<HTMLDivElement>(null);
const [selectedIndex, setSelectedIndex] = useState(0);
const [adjustedPosition, setAdjustedPosition] = useState(position);
console.log('🖱️ TOOLTIP COMPONENT: Rendering with props:', {
isVisible,
position,
suggestions,
misspelledWord
});
// Calculate smart positioning to avoid window edges
useEffect(() => {
if (!isVisible) {
setAdjustedPosition(position);
return;
}
// Use a timeout to allow the tooltip to render first
const timer = setTimeout(() => {
if (!tooltipRef.current) return;
const tooltip = tooltipRef.current;
const tooltipRect = tooltip.getBoundingClientRect();
const windowWidth = window.innerWidth;
const windowHeight = window.innerHeight;
let newX = position.x;
let newY = position.y;
let transform = 'translateX(-50%) translateY(-100%)'; // Default: center horizontally, above word
// Check horizontal boundaries
const tooltipWidth = tooltipRect.width || 200; // Fallback width
const halfWidth = tooltipWidth / 2;
const margin = 10; // Margin from window edges
if (newX - halfWidth < margin) {
// Too close to left edge - align to left
newX = margin;
transform = 'translateY(-100%)'; // Remove horizontal centering
} else if (newX + halfWidth > windowWidth - margin) {
// Too close to right edge - align to right
newX = windowWidth - margin;
transform = 'translateX(-100%) translateY(-100%)'; // Align to right edge
}
// Check vertical boundaries
const tooltipHeight = tooltipRect.height || 150; // Fallback height
if (newY - tooltipHeight < margin) {
// Not enough space above - show below the word
newY = position.y + 30; // Position below word
if (transform.includes('translateX(-50%)')) {
transform = 'translateX(-50%) translateY(0%)'; // Center horizontally, below word
} else if (transform.includes('translateX(-100%)')) {
transform = 'translateX(-100%) translateY(0%)'; // Right align, below word
} else {
transform = 'translateY(0%)'; // Left align, below word
}
}
setAdjustedPosition({ x: newX, y: newY });
// Apply the transform
tooltip.style.transform = transform;
console.log('🖱️ TOOLTIP POSITIONING:', {
original: position,
adjusted: { x: newX, y: newY },
transform,
windowSize: { width: windowWidth, height: windowHeight },
tooltipSize: { width: tooltipWidth, height: tooltipHeight }
});
}, 0);
return () => clearTimeout(timer);
}, [isVisible, position]);
// Handle keyboard navigation
useEffect(() => {
if (!isVisible) return;
const handleKeyDown = (e: KeyboardEvent) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setSelectedIndex(prev =>
Math.min(prev + 1, suggestions.length - 1)
);
break;
case 'ArrowUp':
e.preventDefault();
setSelectedIndex(prev => Math.max(prev - 1, 0));
break;
case 'Enter':
e.preventDefault();
if (suggestions[selectedIndex]) {
onSuggestionSelect(suggestions[selectedIndex]);
}
break;
case 'Escape':
e.preventDefault();
onIgnore(); // Close tooltip on escape
break;
case '1':
case '2':
case '3':
case '4':
case '5':
e.preventDefault();
const numIndex = parseInt(e.key) - 1;
if (suggestions[numIndex]) {
onSuggestionSelect(suggestions[numIndex]);
}
break;
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isVisible, suggestions, selectedIndex, onSuggestionSelect, onIgnore]);
// Reset selected index when suggestions change
useEffect(() => {
setSelectedIndex(0);
}, [suggestions]);
// Auto-focus tooltip when it becomes visible
useEffect(() => {
if (isVisible && tooltipRef.current) {
tooltipRef.current.focus();
}
}, [isVisible]);
if (!isVisible) {
console.log('🖱️ TOOLTIP COMPONENT: Not visible, returning null');
return null;
}
console.log('🖱️ TOOLTIP COMPONENT: Rendering visible tooltip');
return (
<div
ref={tooltipRef}
tabIndex={-1} // Make it focusable for keyboard events
data-spell-tooltip="true" // For click detection
className="fixed z-50 bg-background-default border border-border-default rounded-lg shadow-xl py-2 min-w-48 max-w-64 outline-none"
style={{
left: adjustedPosition.x,
top: adjustedPosition.y - 8, // Position slightly above the word
transform: 'translateX(-50%) translateY(-100%)', // Will be overridden by smart positioning
boxShadow: '0 10px 25px rgba(0, 0, 0, 0.15), 0 4px 6px rgba(0, 0, 0, 0.1)',
}}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
{/* Header */}
<div className="px-3 py-1 text-xs text-text-muted border-b border-border-subtle mb-1 font-medium">
Suggestions for "<span className="text-red-600 dark:text-red-400 font-semibold">{misspelledWord}</span>"
</div>
{/* Suggestions */}
{suggestions.length > 0 ? (
<div className="max-h-32 overflow-y-auto">
{suggestions.slice(0, 5).map((suggestion, index) => (
<button
key={index}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
console.log('🖱️ SUGGESTION CLICKED:', suggestion);
onSuggestionSelect(suggestion);
}}
onMouseEnter={() => setSelectedIndex(index)}
className={`w-full text-left px-3 py-2 text-sm transition-all duration-150 flex items-center gap-2 ${
selectedIndex === index
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-900 dark:text-blue-100 border-l-2 border-blue-500'
: 'text-text-default hover:bg-background-subtle'
}`}
>
<span
className={`w-5 h-5 flex items-center justify-center text-xs rounded text-[10px] font-bold ${
selectedIndex === index
? 'bg-blue-500 text-white'
: 'bg-text-muted text-white'
}`}
>
{index + 1}
</span>
<span className="font-medium truncate">{suggestion}</span>
</button>
))}
</div>
) : (
<div className="px-3 py-2 text-sm text-text-muted italic">
No suggestions available
</div>
)}
{/* Separator */}
<div className="border-t border-border-subtle my-1" />
{/* Additional actions */}
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
console.log('🖱️ ADD TO DICTIONARY CLICKED');
onAddToDictionary();
}}
className="w-full text-left px-3 py-1.5 text-xs text-text-muted hover:bg-background-subtle transition-colors flex items-center gap-2"
>
<span className="text-green-600 dark:text-green-400">+</span>
Add to dictionary
</button>
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
console.log('🖱️ IGNORE CLICKED');
onIgnore();
}}
className="w-full text-left px-3 py-1.5 text-xs text-text-muted hover:bg-background-subtle transition-colors flex items-center gap-2"
>
<span className="text-text-muted">×</span>
Ignore word
</button>
{/* Keyboard hints */}
<div className="px-3 py-1 text-[10px] text-text-muted border-t border-border-subtle mt-1">
Press 1-5 to select to navigate Enter to apply Esc to close
</div>
</div>
);
};
export default SpellCheckTooltip;
-291
View File
@@ -2136,298 +2136,7 @@ async function appMain() {
}
});
// Handle spell checking requests using system spell checker
ipcMain.handle('spell-check', async (event, word: string) => {
try {
console.log('[Main] System spell check request for word:', word);
if (!word || typeof word !== 'string') {
return true; // Assume correct for invalid input
}
// Skip very short words (less than 3 characters)
if (word.length < 3) {
return true;
}
const cleanWord = word.trim();
try {
// Use system spell checker based on platform
if (process.platform === 'darwin') {
// macOS: Use aspell (now that it's installed)
const { spawn } = require('child_process');
return new Promise((resolve) => {
const aspellProcess = spawn('aspell', ['-a'], {
stdio: ['pipe', 'pipe', 'pipe'],
timeout: 3000
});
let output = '';
aspellProcess.stdout.on('data', (data) => {
output += data.toString();
});
aspellProcess.on('close', (code) => {
// Parse aspell output
const lines = output.split('\n').filter(line => line.trim());
let isCorrect = true;
for (const line of lines) {
if (line.startsWith('*')) {
// Word is correct
isCorrect = true;
break;
} else if (line.startsWith('&') || line.startsWith('#')) {
// Word is misspelled
isCorrect = false;
break;
}
}
console.log('[Main] macOS aspell spell check result for', word, ':', isCorrect);
resolve(isCorrect);
});
aspellProcess.on('error', (error) => {
console.error('[Main] aspell error:', error);
resolve(true); // Default to correct if aspell not available
});
aspellProcess.stdin.write(cleanWord + '\n');
aspellProcess.stdin.end();
setTimeout(() => {
aspellProcess.kill();
resolve(true);
}, 3000);
});
} else if (process.platform === 'win32') {
// Windows: Try to use hunspell or fall back to basic check
return new Promise((resolve) => {
const { spawn } = require('child_process');
// Try hunspell first (if available)
const hunspellProcess = spawn('hunspell', ['-d', 'en_US'], {
stdio: ['pipe', 'pipe', 'pipe'],
timeout: 3000
});
let output = '';
hunspellProcess.stdout.on('data', (data) => {
output += data.toString();
});
hunspellProcess.on('close', (code) => {
// hunspell returns "*" for correct words, "&" for incorrect
const isCorrect = output.includes('*') || output.trim() === '';
console.log('[Main] Windows spell check result for', word, ':', isCorrect);
resolve(isCorrect);
});
hunspellProcess.on('error', (error) => {
console.error('[Main] hunspell not available, defaulting to correct:', error);
resolve(true); // Default to correct if hunspell not available
});
hunspellProcess.stdin.write(cleanWord + '\n');
hunspellProcess.stdin.end();
setTimeout(() => {
hunspellProcess.kill();
resolve(true);
}, 3000);
});
} else {
// Linux: Use aspell or hunspell
return new Promise((resolve) => {
const { spawn } = require('child_process');
const aspellProcess = spawn('aspell', ['-a'], {
stdio: ['pipe', 'pipe', 'pipe'],
timeout: 3000
});
let output = '';
aspellProcess.stdout.on('data', (data) => {
output += data.toString();
});
aspellProcess.on('close', (code) => {
// Parse aspell output
const lines = output.split('\n').filter(line => line.trim());
let isCorrect = true;
for (const line of lines) {
if (line.startsWith('*')) {
isCorrect = true;
break;
} else if (line.startsWith('&') || line.startsWith('#')) {
isCorrect = false;
break;
}
}
console.log('[Main] Linux spell check result for', word, ':', isCorrect);
resolve(isCorrect);
});
aspellProcess.on('error', (error) => {
console.error('[Main] aspell error:', error);
resolve(true); // Default to correct if aspell not available
});
aspellProcess.stdin.write(cleanWord + '\n');
aspellProcess.stdin.end();
setTimeout(() => {
aspellProcess.kill();
resolve(true);
}, 3000);
});
}
} catch (error) {
console.error('[Main] Error using system spell checker:', error);
return true; // Default to correct on error
}
} catch (error) {
console.error('Error in system spell-check handler:', error);
return true; // Assume correct on error
}
});
ipcMain.handle('spell-suggestions', async (event, word: string) => {
try {
console.log('[Main] System spell suggestions request for word:', word);
if (!word || typeof word !== 'string') {
return [];
}
// Skip very short words
if (word.length < 3) {
return [];
}
const cleanWord = word.trim();
try {
// Get suggestions using system spell checker based on platform
if (process.platform === 'darwin' || process.platform === 'linux') {
// macOS and Linux: Use aspell for suggestions
const { spawn } = require('child_process');
return new Promise((resolve) => {
const aspellProcess = spawn('aspell', ['-a'], {
stdio: ['pipe', 'pipe', 'pipe'],
timeout: 3000
});
let output = '';
aspellProcess.stdout.on('data', (data) => {
output += data.toString();
});
aspellProcess.on('close', (code) => {
// Parse aspell output for suggestions
const lines = output.split('\n').filter(line => line.trim());
let suggestions: string[] = [];
for (const line of lines) {
if (line.startsWith('&')) {
// Line format: & word count offset: suggestion1, suggestion2, ...
const parts = line.split(':');
if (parts.length > 1) {
const suggestionsPart = parts[1].trim();
suggestions = suggestionsPart.split(',').map(s => s.trim()).slice(0, 5); // Limit to 5 suggestions
}
break;
} else if (line.startsWith('#')) {
// No suggestions available
suggestions = [];
break;
}
}
console.log('[Main] aspell spell suggestions for', word, ':', suggestions);
resolve(suggestions);
});
aspellProcess.on('error', (error) => {
console.error('[Main] aspell error getting suggestions:', error);
resolve([]); // Return empty array on error
});
aspellProcess.stdin.write(cleanWord + '\n');
aspellProcess.stdin.end();
setTimeout(() => {
aspellProcess.kill();
resolve([]);
}, 3000);
});
} else if (process.platform === 'win32') {
// Windows: Try to use hunspell for suggestions
return new Promise((resolve) => {
const { spawn } = require('child_process');
const hunspellProcess = spawn('hunspell', ['-d', 'en_US', '-s'], {
stdio: ['pipe', 'pipe', 'pipe'],
timeout: 3000
});
let output = '';
hunspellProcess.stdout.on('data', (data) => {
output += data.toString();
});
hunspellProcess.on('close', (code) => {
// Parse hunspell suggestions
const lines = output.split('\n').filter(line => line.trim());
const suggestions = lines.slice(0, 5); // Limit to 5 suggestions
console.log('[Main] hunspell spell suggestions for', word, ':', suggestions);
resolve(suggestions);
});
hunspellProcess.on('error', (error) => {
console.error('[Main] hunspell not available for suggestions:', error);
resolve([]); // Return empty array if hunspell not available
});
hunspellProcess.stdin.write(cleanWord + '\n');
hunspellProcess.stdin.end();
setTimeout(() => {
hunspellProcess.kill();
resolve([]);
}, 3000);
});
}
return [];
} catch (error) {
console.error('[Main] Error getting spell suggestions:', error);
return [];
}
} catch (error) {
console.error('Error in system spell-suggestions handler:', error);
return [];
}
});
}