From 8f02a51297c3e6e20380c4d64e52907d8d3f02ae Mon Sep 17 00:00:00 2001 From: kaigler Date: Fri, 30 Jan 2026 04:43:49 -0600 Subject: [PATCH] feat(ui): add spell check support for text inputs (#1304) * feat: add spell check with context menu and language sync Enables spell checking in text inputs with: - Right-click context menu with spelling suggestions - "Add to Dictionary" option (localized for en/fr) - Standard editing options (cut/copy/paste/select all) - Spell check language syncs with i18n app language - Input/Textarea components default spellCheck=true and lang attribute Files: - app-language.ts: Tracks app language for context menu labels - spellcheck.ts: Language mapping and localized labels - index.ts: Context menu handler with spell check integration - settings-handlers.ts: IPC handler for language switching - App.tsx: Syncs spell check language with i18n changes Supersedes PR #1304 (rebased from conflicting branch) Co-Authored-By: Claude Opus 4.5 * fix: use i18n.language in useEffect dependency instead of i18n object The i18n object reference from react-i18next can change on every render even when the language hasn't changed, causing the effect to fire more frequently than necessary and making unnecessary IPC calls. Changed dependency from [settings.language, i18n] to [settings.language, i18n.language] to only re-run when the actual language changes. Co-Authored-By: Claude Opus 4.5 * fix: call initAppLanguage() on startup for immediate locale sync initAppLanguage() was defined but never called, leaving the main process language hardcoded to 'en' until the renderer sent the first IPC sync. Now called in app.whenReady() so context menu labels (e.g. "Add to Dictionary") are localized immediately from the OS locale. Co-Authored-By: Claude Opus 4.5 --------- Co-authored-by: Claude Opus 4.5 Co-authored-by: Andy <119136210+AndyMik90@users.noreply.github.com> --- apps/frontend/src/main/app-language.ts | 45 +++++++++ apps/frontend/src/main/index.ts | 92 ++++++++++++++++++- .../main/ipc-handlers/settings-handlers.ts | 65 ++++++++++++- apps/frontend/src/preload/api/settings-api.ts | 9 +- apps/frontend/src/renderer/App.tsx | 19 +++- .../src/renderer/components/ui/input.tsx | 61 ++++++------ .../src/renderer/components/ui/textarea.tsx | 59 ++++++------ .../src/renderer/lib/mocks/settings-mock.ts | 3 + apps/frontend/src/shared/constants/index.ts | 77 ++++++++-------- apps/frontend/src/shared/constants/ipc.ts | 3 + .../src/shared/constants/spellcheck.ts | 30 ++++++ apps/frontend/src/shared/types/ipc.ts | 3 + 12 files changed, 367 insertions(+), 99 deletions(-) create mode 100644 apps/frontend/src/main/app-language.ts create mode 100644 apps/frontend/src/shared/constants/spellcheck.ts diff --git a/apps/frontend/src/main/app-language.ts b/apps/frontend/src/main/app-language.ts new file mode 100644 index 00000000..7a29ca87 --- /dev/null +++ b/apps/frontend/src/main/app-language.ts @@ -0,0 +1,45 @@ +/** + * App language tracking module for main process. + * + * Tracks the user's in-app language setting (not OS locale) for use in + * main process code that needs localized strings (e.g., context menus). + * + * Updated via IPC when user changes language in settings. + */ + +import { app } from 'electron'; + +// Current app language, defaults to 'en' +// Updated via setAppLanguage() when renderer notifies of language change +let currentAppLanguage = 'en'; + +/** + * Get the current app language. + * Falls back to 'en' if not set. + */ +export function getAppLanguage(): string { + return currentAppLanguage; +} + +/** + * Set the current app language. + * Called by IPC handler when renderer changes language. + */ +export function setAppLanguage(language: string): void { + currentAppLanguage = language; +} + +/** + * Initialize app language from OS locale as a starting point. + * The renderer will update this once i18n initializes. + */ +export function initAppLanguage(): void { + try { + // app.getLocale() may not be available in test environments + const osLocale = app?.getLocale?.() || 'en'; + // Extract base language (e.g., 'en-US' -> 'en') + currentAppLanguage = osLocale.split('-')[0] || 'en'; + } catch { + currentAppLanguage = 'en'; + } +} diff --git a/apps/frontend/src/main/index.ts b/apps/frontend/src/main/index.ts index 3366e831..8f68abf6 100644 --- a/apps/frontend/src/main/index.ts +++ b/apps/frontend/src/main/index.ts @@ -35,7 +35,7 @@ for (const envPath of possibleEnvPaths) { } } -import { app, BrowserWindow, shell, nativeImage, session, screen } from 'electron'; +import { app, BrowserWindow, shell, nativeImage, session, screen, Menu, MenuItem } from 'electron'; import { join } from 'path'; import { accessSync, readFileSync, writeFileSync, rmSync } from 'fs'; import { electronApp, optimizer, is } from '@electron-toolkit/utils'; @@ -46,7 +46,8 @@ import { pythonEnvManager } from './python-env-manager'; import { getUsageMonitor } from './claude-profile/usage-monitor'; import { initializeUsageMonitorForwarding } from './ipc-handlers/terminal-handlers'; import { initializeAppUpdater, stopPeriodicUpdates } from './app-updater'; -import { DEFAULT_APP_SETTINGS, IPC_CHANNELS } from '../shared/constants'; +import { DEFAULT_APP_SETTINGS, IPC_CHANNELS, SPELL_CHECK_LANGUAGE_MAP, DEFAULT_SPELL_CHECK_LANGUAGE, ADD_TO_DICTIONARY_LABELS } from '../shared/constants'; +import { getAppLanguage, initAppLanguage } from './app-language'; import { readSettingsFile } from './settings-utils'; import { setupErrorLogging } from './app-logger'; import { initSentryMain } from './sentry'; @@ -204,7 +205,8 @@ function createWindow(): void { sandbox: false, contextIsolation: true, nodeIntegration: false, - backgroundThrottling: false // Prevent terminal lag when window loses focus + backgroundThrottling: false, // Prevent terminal lag when window loses focus + spellcheck: true // Enable spell check for text inputs } }); @@ -213,6 +215,87 @@ function createWindow(): void { mainWindow?.show(); }); + // Configure initial spell check languages with proper fallback logic + // Uses shared constant for consistency with the IPC handler + const defaultLanguage = 'en'; + const defaultSpellCheckLanguages = SPELL_CHECK_LANGUAGE_MAP[defaultLanguage] || [DEFAULT_SPELL_CHECK_LANGUAGE]; + const availableSpellCheckLanguages = session.defaultSession.availableSpellCheckerLanguages; + const validSpellCheckLanguages = defaultSpellCheckLanguages.filter(lang => + availableSpellCheckLanguages.includes(lang) + ); + const initialSpellCheckLanguages = validSpellCheckLanguages.length > 0 + ? validSpellCheckLanguages + : (availableSpellCheckLanguages.includes(DEFAULT_SPELL_CHECK_LANGUAGE) ? [DEFAULT_SPELL_CHECK_LANGUAGE] : []); + + if (initialSpellCheckLanguages.length > 0) { + session.defaultSession.setSpellCheckerLanguages(initialSpellCheckLanguages); + console.log(`[SPELLCHECK] Initial languages set to: ${initialSpellCheckLanguages.join(', ')}`); + } else { + console.warn('[SPELLCHECK] No spell check languages available on this system'); + } + + // Handle context menu with spell check and standard editing options + mainWindow.webContents.on('context-menu', (_event, params) => { + const menu = new Menu(); + + // Add spelling suggestions if there's a misspelled word + if (params.misspelledWord) { + for (const suggestion of params.dictionarySuggestions) { + menu.append(new MenuItem({ + label: suggestion, + click: () => mainWindow?.webContents.replaceMisspelling(suggestion) + })); + } + + if (params.dictionarySuggestions.length > 0) { + menu.append(new MenuItem({ type: 'separator' })); + } + + // Use localized label for "Add to Dictionary" based on app language (not OS locale) + // getAppLanguage() tracks the user's in-app language setting, updated via SPELLCHECK_SET_LANGUAGES IPC + const addToDictionaryLabel = ADD_TO_DICTIONARY_LABELS[getAppLanguage()] || ADD_TO_DICTIONARY_LABELS['en']; + menu.append(new MenuItem({ + label: addToDictionaryLabel, + click: () => mainWindow?.webContents.session.addWordToSpellCheckerDictionary(params.misspelledWord) + })); + + menu.append(new MenuItem({ type: 'separator' })); + } + + // Standard editing options for editable fields + // Using role without explicit label allows Electron to provide localized labels + if (params.isEditable) { + menu.append(new MenuItem({ + role: 'cut', + enabled: params.editFlags.canCut + })); + menu.append(new MenuItem({ + role: 'copy', + enabled: params.editFlags.canCopy + })); + menu.append(new MenuItem({ + role: 'paste', + enabled: params.editFlags.canPaste + })); + menu.append(new MenuItem({ + role: 'selectAll', + enabled: params.editFlags.canSelectAll + })); + } else if (params.selectionText?.trim()) { + // Non-editable text selection (e.g., labels, paragraphs) + // Use .trim() to avoid showing menu for whitespace-only selections + menu.append(new MenuItem({ + role: 'copy', + enabled: params.editFlags.canCopy + })); + } + + // Only show menu if there are items + if (menu.items.length > 0) { + menu.popup(); + } + }); + // Handle external links with URL scheme allowlist for security // Note: Terminal links now use IPC via WebLinksAddon callback, but this handler // catches any other window.open() calls (e.g., from third-party libraries) @@ -278,6 +361,9 @@ app.whenReady().then(() => { .catch((err) => console.warn('[main] Failed to clear cache:', err)); } + // Initialize app language from OS locale for main process i18n (context menus) + initAppLanguage(); + // Clean up stale update metadata from the old source updater system // This prevents version display desync after electron-updater installs a new version cleanupStaleUpdateMetadata(); diff --git a/apps/frontend/src/main/ipc-handlers/settings-handlers.ts b/apps/frontend/src/main/ipc-handlers/settings-handlers.ts index 82df85a6..4b1ba2e1 100644 --- a/apps/frontend/src/main/ipc-handlers/settings-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/settings-handlers.ts @@ -1,4 +1,4 @@ -import { ipcMain, dialog, app, shell } from 'electron'; +import { ipcMain, dialog, app, shell, session } from 'electron'; import { existsSync, writeFileSync, mkdirSync, statSync, readFileSync } from 'fs'; import { execFileSync } from 'node:child_process'; import path from 'path'; @@ -8,7 +8,8 @@ import { is } from '@electron-toolkit/utils'; // ESM-compatible __dirname const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); -import { IPC_CHANNELS, DEFAULT_APP_SETTINGS, DEFAULT_AGENT_PROFILES } from '../../shared/constants'; +import { IPC_CHANNELS, DEFAULT_APP_SETTINGS, DEFAULT_AGENT_PROFILES, SPELL_CHECK_LANGUAGE_MAP, DEFAULT_SPELL_CHECK_LANGUAGE } from '../../shared/constants'; +import { setAppLanguage } from '../app-language'; import type { AppSettings, IPCResult, @@ -801,4 +802,64 @@ export function registerSettingsHandlers( } } ); + + // ============================================ + // Spell Check Operations + // ============================================ + + /** + * Set spell check languages based on app language. + * Called when renderer's i18n language changes to sync spell checker. + */ + ipcMain.handle( + IPC_CHANNELS.SPELLCHECK_SET_LANGUAGES, + async (_, language: string): Promise> => { + try { + // Validate language parameter + if (!language || typeof language !== 'string') { + return { + success: false, + error: 'Invalid language parameter' + }; + } + + // Update tracked app language for context menu labels + setAppLanguage(language); + + // Get spell check languages for this app language + const spellCheckLanguages = SPELL_CHECK_LANGUAGE_MAP[language] || [DEFAULT_SPELL_CHECK_LANGUAGE]; + + // Get available languages on this system + const availableLanguages = session.defaultSession.availableSpellCheckerLanguages; + + // Filter to only available languages + const validLanguages = spellCheckLanguages.filter(lang => + availableLanguages.includes(lang) + ); + + // Fallback to default if none of the preferred languages are available + const languagesToSet = validLanguages.length > 0 + ? validLanguages + : (availableLanguages.includes(DEFAULT_SPELL_CHECK_LANGUAGE) ? [DEFAULT_SPELL_CHECK_LANGUAGE] : []); + + if (languagesToSet.length > 0) { + session.defaultSession.setSpellCheckerLanguages(languagesToSet); + console.log(`[SPELLCHECK] Languages set to: ${languagesToSet.join(', ')} for app language: ${language}`); + } else { + console.warn(`[SPELLCHECK] No valid spell check languages available for: ${language}`); + } + + return { + success: true, + data: { success: true } + }; + } catch (error) { + console.error('[SPELLCHECK_SET_LANGUAGES] Error:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to set spell check languages' + }; + } + } + ); } diff --git a/apps/frontend/src/preload/api/settings-api.ts b/apps/frontend/src/preload/api/settings-api.ts index 36cdb0f9..c9e84885 100644 --- a/apps/frontend/src/preload/api/settings-api.ts +++ b/apps/frontend/src/preload/api/settings-api.ts @@ -36,6 +36,9 @@ export interface SettingsAPI { notifySentryStateChanged: (enabled: boolean) => void; getSentryDsn: () => Promise; getSentryConfig: () => Promise<{ dsn: string; tracesSampleRate: number; profilesSampleRate: number }>; + + // Spell check + setSpellCheckLanguages: (language: string) => Promise>; } export const createSettingsAPI = (): SettingsAPI => ({ @@ -83,5 +86,9 @@ export const createSettingsAPI = (): SettingsAPI => ({ // Get full Sentry config from main process (DSN + sample rates) getSentryConfig: (): Promise<{ dsn: string; tracesSampleRate: number; profilesSampleRate: number }> => - ipcRenderer.invoke(IPC_CHANNELS.GET_SENTRY_CONFIG) + ipcRenderer.invoke(IPC_CHANNELS.GET_SENTRY_CONFIG), + + // Spell check - sync spell checker language with app language + setSpellCheckLanguages: (language: string): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.SPELLCHECK_SET_LANGUAGES, language) }); diff --git a/apps/frontend/src/renderer/App.tsx b/apps/frontend/src/renderer/App.tsx index ef125b65..f0b22d88 100644 --- a/apps/frontend/src/renderer/App.tsx +++ b/apps/frontend/src/renderer/App.tsx @@ -313,7 +313,24 @@ export function App() { if (settings.language && settings.language !== i18n.language) { i18n.changeLanguage(settings.language); } - }, [settings.language, i18n]); + // eslint-disable-next-line react-hooks/exhaustive-deps -- Only run when settings.language changes, not on every i18n object change + }, [settings.language, i18n.language]); + + // Sync spell check language with i18n language + useEffect(() => { + const syncSpellCheck = async () => { + try { + const result = await window.electronAPI.setSpellCheckLanguages(i18n.language); + if (!result.success) { + console.warn('[App] Failed to set spell check language:', result.error); + } + } catch (error) { + console.warn('[App] Error syncing spell check language:', error); + } + }; + + syncSpellCheck(); + }, [i18n.language]); // Listen for open-app-settings events (e.g., from project settings) useEffect(() => { diff --git a/apps/frontend/src/renderer/components/ui/input.tsx b/apps/frontend/src/renderer/components/ui/input.tsx index 59f3d14a..2b45b4f4 100644 --- a/apps/frontend/src/renderer/components/ui/input.tsx +++ b/apps/frontend/src/renderer/components/ui/input.tsx @@ -1,28 +1,33 @@ -import * as React from 'react'; -import { cn } from '../../lib/utils'; - -export interface InputProps extends React.InputHTMLAttributes {} - -const Input = React.forwardRef( - ({ className, type, ...props }, ref) => { - return ( - - ); - } -); -Input.displayName = 'Input'; - -export { Input }; +import * as React from 'react'; +import { useTranslation } from 'react-i18next'; +import { cn } from '../../lib/utils'; + +export interface InputProps extends React.InputHTMLAttributes {} + +const Input = React.forwardRef( + ({ className, type, spellCheck, lang, ...props }, ref) => { + const { i18n } = useTranslation(); + + return ( + + ); + } +); +Input.displayName = 'Input'; + +export { Input }; diff --git a/apps/frontend/src/renderer/components/ui/textarea.tsx b/apps/frontend/src/renderer/components/ui/textarea.tsx index fb665030..0bfa6d08 100644 --- a/apps/frontend/src/renderer/components/ui/textarea.tsx +++ b/apps/frontend/src/renderer/components/ui/textarea.tsx @@ -1,27 +1,32 @@ -import * as React from 'react'; -import { cn } from '../../lib/utils'; - -export interface TextareaProps extends React.TextareaHTMLAttributes {} - -const Textarea = React.forwardRef( - ({ className, ...props }, ref) => { - return ( -