AI Grammar Corrector

AI-powered grammar and spelling checker that works on any text input

Size

20.4 KB

Version

1.1.1

Created

Feb 10, 2026

Updated

27 days ago

1// ==UserScript==
2// @name		AI Grammar Corrector
3// @description		AI-powered grammar and spelling checker that works on any text input
4// @version		1.1.1
5// @match		*://*/*
6// @icon		https://static-web.grammarly.com/cms/master/public/favicon.ico
7// ==/UserScript==
8(function() {
9    'use strict';
10
11    // Add styles for the grammar checker UI
12    TM_addStyle(`
13        .grammar-underline {
14            background: linear-gradient(to bottom, transparent 0%, transparent calc(100% - 2px), #ff4444 calc(100% - 2px), #ff4444 100%);
15            background-size: 4px 100%;
16            background-repeat: repeat-x;
17            cursor: pointer;
18            position: relative;
19            border-bottom: 2px dotted #ff4444;
20        }
21
22        .grammar-underline:hover {
23            background-color: rgba(255, 68, 68, 0.1);
24        }
25
26        .grammar-suggestion-popup {
27            position: absolute;
28            background: white;
29            border-radius: 8px;
30            box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
31            z-index: 100000;
32            min-width: 280px;
33            max-width: 400px;
34            padding: 0;
35            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
36            animation: fadeIn 0.2s ease;
37        }
38
39        @keyframes fadeIn {
40            from { opacity: 0; transform: translateY(-5px); }
41            to { opacity: 1; transform: translateY(0); }
42        }
43
44        .grammar-suggestion-header {
45            padding: 12px 16px;
46            border-bottom: 1px solid #e0e0e0;
47            display: flex;
48            align-items: center;
49            gap: 8px;
50        }
51
52        .grammar-suggestion-type {
53            font-size: 11px;
54            font-weight: 700;
55            text-transform: uppercase;
56            letter-spacing: 0.5px;
57            color: #ff4444;
58        }
59
60        .grammar-suggestion-body {
61            padding: 16px;
62        }
63
64        .grammar-suggestion-original {
65            font-size: 13px;
66            color: #666;
67            margin-bottom: 12px;
68            padding: 8px;
69            background: #f5f5f5;
70            border-radius: 4px;
71            text-decoration: line-through;
72        }
73
74        .grammar-suggestion-fix {
75            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
76            color: white;
77            border: none;
78            border-radius: 6px;
79            padding: 10px 16px;
80            font-size: 14px;
81            font-weight: 600;
82            cursor: pointer;
83            width: 100%;
84            transition: all 0.2s;
85            margin-bottom: 8px;
86        }
87
88        .grammar-suggestion-fix:hover {
89            transform: translateY(-1px);
90            box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
91        }
92
93        .grammar-suggestion-description {
94            font-size: 13px;
95            color: #666;
96            line-height: 1.5;
97            margin-top: 8px;
98            padding-top: 8px;
99            border-top: 1px solid #e0e0e0;
100        }
101
102        .grammar-checking-indicator {
103            position: fixed;
104            bottom: 20px;
105            right: 20px;
106            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
107            color: white;
108            padding: 12px 20px;
109            border-radius: 8px;
110            box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
111            z-index: 99999;
112            font-size: 13px;
113            font-weight: 600;
114            display: flex;
115            align-items: center;
116            gap: 10px;
117            animation: slideIn 0.3s ease;
118        }
119
120        @keyframes slideIn {
121            from { transform: translateX(400px); }
122            to { transform: translateX(0); }
123        }
124
125        .grammar-spinner {
126            display: inline-block;
127            width: 14px;
128            height: 14px;
129            border: 2px solid rgba(255, 255, 255, 0.3);
130            border-top-color: white;
131            border-radius: 50%;
132            animation: spin 0.6s linear infinite;
133        }
134
135        @keyframes spin {
136            to { transform: rotate(360deg); }
137        }
138
139        .grammar-status-badge {
140            position: absolute;
141            bottom: 8px;
142            right: 8px;
143            background: #4caf50;
144            color: white;
145            padding: 4px 10px;
146            border-radius: 12px;
147            font-size: 11px;
148            font-weight: 600;
149            z-index: 1000;
150            pointer-events: none;
151            box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
152        }
153
154        .grammar-status-badge.has-errors {
155            background: #ff4444;
156        }
157
158        .grammar-overlay {
159            position: absolute;
160            pointer-events: none;
161            white-space: pre-wrap;
162            word-wrap: break-word;
163            overflow-wrap: break-word;
164            z-index: 1;
165            color: transparent;
166            line-height: inherit;
167            font-family: inherit;
168            font-size: inherit;
169            padding: inherit;
170            border: inherit;
171            margin: 0;
172        }
173
174        .grammar-overlay-error {
175            border-bottom: 2px solid #ff4444;
176            background: rgba(255, 68, 68, 0.1);
177            cursor: pointer;
178            pointer-events: auto;
179            border-radius: 2px;
180        }
181
182        .grammar-overlay-error:hover {
183            background: rgba(255, 68, 68, 0.2);
184        }
185    `);
186
187    let activeTextAreas = new Map();
188    let currentPopup = null;
189    let checkingIndicator = null;
190
191    // Debounce function
192    function debounce(func, wait) {
193        let timeout;
194        return function executedFunction(...args) {
195            const later = () => {
196                clearTimeout(timeout);
197                func(...args);
198            };
199            clearTimeout(timeout);
200            timeout = setTimeout(later, wait);
201        };
202    }
203
204    // Show checking indicator
205    function showCheckingIndicator() {
206        if (checkingIndicator) return;
207        
208        checkingIndicator = document.createElement('div');
209        checkingIndicator.className = 'grammar-checking-indicator';
210        checkingIndicator.innerHTML = '<span class="grammar-spinner"></span> Checking grammar...';
211        document.body.appendChild(checkingIndicator);
212    }
213
214    // Hide checking indicator
215    function hideCheckingIndicator() {
216        if (checkingIndicator) {
217            checkingIndicator.remove();
218            checkingIndicator = null;
219        }
220    }
221
222    // Check grammar using AI
223    async function checkGrammar(text) {
224        console.log('Checking grammar for text:', text.substring(0, 50) + '...');
225        
226        try {
227            const result = await RM.aiCall(
228                `Analyze this text for grammar, spelling, and style errors. For each error, provide the exact text that has the error, the correction, and explanation.
229
230Text: "${text}"
231
232Find all errors and provide detailed corrections.`,
233                {
234                    type: 'json_schema',
235                    json_schema: {
236                        name: 'grammar_check',
237                        schema: {
238                            type: 'object',
239                            properties: {
240                                errors: {
241                                    type: 'array',
242                                    items: {
243                                        type: 'object',
244                                        properties: {
245                                            errorText: {
246                                                type: 'string',
247                                                description: 'The exact text that contains the error'
248                                            },
249                                            correction: {
250                                                type: 'string',
251                                                description: 'The corrected version'
252                                            },
253                                            type: {
254                                                type: 'string',
255                                                enum: ['grammar', 'spelling', 'punctuation', 'style', 'clarity'],
256                                                description: 'Type of error'
257                                            },
258                                            explanation: {
259                                                type: 'string',
260                                                description: 'Brief explanation of the error'
261                                            },
262                                            startIndex: {
263                                                type: 'number',
264                                                description: 'Starting position of error in text'
265                                            },
266                                            endIndex: {
267                                                type: 'number',
268                                                description: 'Ending position of error in text'
269                                            }
270                                        },
271                                        required: ['errorText', 'correction', 'type', 'explanation']
272                                    }
273                                },
274                                hasErrors: {
275                                    type: 'boolean',
276                                    description: 'Whether any errors were found'
277                                }
278                            },
279                            required: ['errors', 'hasErrors']
280                        }
281                    }
282                }
283            );
284
285            // Find positions of errors in the text
286            if (result.errors && result.errors.length > 0) {
287                result.errors = result.errors.map(error => {
288                    if (error.startIndex === undefined) {
289                        const index = text.indexOf(error.errorText);
290                        if (index !== -1) {
291                            error.startIndex = index;
292                            error.endIndex = index + error.errorText.length;
293                        }
294                    }
295                    return error;
296                }).filter(error => error.startIndex !== undefined);
297            }
298
299            console.log('Grammar check result:', result);
300            return result;
301        } catch (error) {
302            console.error('Grammar check failed:', error);
303            return { errors: [], hasErrors: false };
304        }
305    }
306
307    // Create overlay for highlighting errors
308    function createOverlay(textarea, errors) {
309        // Remove existing overlay
310        const existingOverlay = textarea.parentElement.querySelector('.grammar-overlay');
311        if (existingOverlay) {
312            existingOverlay.remove();
313        }
314
315        if (!errors || errors.length === 0) return;
316
317        const overlay = document.createElement('div');
318        overlay.className = 'grammar-overlay';
319        
320        // Copy styles from textarea
321        const computedStyle = window.getComputedStyle(textarea);
322        overlay.style.position = 'absolute';
323        overlay.style.top = textarea.offsetTop + 'px';
324        overlay.style.left = textarea.offsetLeft + 'px';
325        overlay.style.width = textarea.offsetWidth + 'px';
326        overlay.style.height = textarea.offsetHeight + 'px';
327        overlay.style.fontSize = computedStyle.fontSize;
328        overlay.style.fontFamily = computedStyle.fontFamily;
329        overlay.style.lineHeight = computedStyle.lineHeight;
330        overlay.style.padding = computedStyle.padding;
331        overlay.style.border = 'none';
332        overlay.style.overflow = 'hidden';
333
334        const text = textarea.value;
335        let lastIndex = 0;
336        let html = '';
337
338        // Sort errors by position
339        const sortedErrors = [...errors].sort((a, b) => a.startIndex - b.startIndex);
340
341        sortedErrors.forEach((error, index) => {
342            // Add text before error
343            html += escapeHtml(text.substring(lastIndex, error.startIndex));
344            
345            // Add error with underline
346            html += `<span class="grammar-overlay-error" data-error-index="${index}">${escapeHtml(error.errorText)}</span>`;
347            
348            lastIndex = error.endIndex;
349        });
350
351        // Add remaining text
352        html += escapeHtml(text.substring(lastIndex));
353
354        overlay.innerHTML = html;
355
356        // Position overlay
357        if (textarea.parentElement.style.position !== 'relative' && 
358            textarea.parentElement.style.position !== 'absolute') {
359            textarea.parentElement.style.position = 'relative';
360        }
361
362        textarea.parentElement.insertBefore(overlay, textarea);
363
364        // Add click handlers to errors
365        overlay.querySelectorAll('.grammar-overlay-error').forEach(errorSpan => {
366            errorSpan.addEventListener('click', (e) => {
367                const errorIndex = parseInt(errorSpan.dataset.errorIndex);
368                const error = sortedErrors[errorIndex];
369                showSuggestionPopup(error, errorSpan, textarea);
370                e.stopPropagation();
371            });
372        });
373
374        return overlay;
375    }
376
377    // Show suggestion popup
378    function showSuggestionPopup(error, element, textarea) {
379        // Remove existing popup
380        if (currentPopup) {
381            currentPopup.remove();
382        }
383
384        const popup = document.createElement('div');
385        popup.className = 'grammar-suggestion-popup';
386
387        popup.innerHTML = `
388            <div class="grammar-suggestion-header">
389                <span class="grammar-suggestion-type">${error.type}</span>
390            </div>
391            <div class="grammar-suggestion-body">
392                <div class="grammar-suggestion-original">${escapeHtml(error.errorText)}</div>
393                <button class="grammar-suggestion-fix">${escapeHtml(error.correction)}</button>
394                <div class="grammar-suggestion-description">${escapeHtml(error.explanation)}</div>
395            </div>
396        `;
397
398        document.body.appendChild(popup);
399
400        // Position popup
401        const rect = element.getBoundingClientRect();
402        popup.style.position = 'fixed';
403        popup.style.top = (rect.bottom + 10) + 'px';
404        popup.style.left = rect.left + 'px';
405
406        // Adjust if popup goes off screen
407        const popupRect = popup.getBoundingClientRect();
408        if (popupRect.right > window.innerWidth) {
409            popup.style.left = (window.innerWidth - popupRect.width - 20) + 'px';
410        }
411        if (popupRect.bottom > window.innerHeight) {
412            popup.style.top = (rect.top - popupRect.height - 10) + 'px';
413        }
414
415        currentPopup = popup;
416
417        // Handle fix button click
418        popup.querySelector('.grammar-suggestion-fix').addEventListener('click', () => {
419            applyFix(textarea, error);
420            popup.remove();
421            currentPopup = null;
422        });
423
424        // Close popup when clicking outside
425        setTimeout(() => {
426            document.addEventListener('click', function closePopup(e) {
427                if (!popup.contains(e.target) && !element.contains(e.target)) {
428                    popup.remove();
429                    currentPopup = null;
430                    document.removeEventListener('click', closePopup);
431                }
432            });
433        }, 100);
434    }
435
436    // Apply fix to textarea
437    function applyFix(textarea, error) {
438        const text = textarea.value;
439        const newText = text.substring(0, error.startIndex) + error.correction + text.substring(error.endIndex);
440        
441        textarea.value = newText;
442        textarea.dispatchEvent(new Event('input', { bubbles: true }));
443        textarea.dispatchEvent(new Event('change', { bubbles: true }));
444
445        // Re-check grammar after fix
446        setTimeout(() => {
447            handleTextChange(textarea);
448        }, 500);
449    }
450
451    // Escape HTML
452    function escapeHtml(text) {
453        const div = document.createElement('div');
454        div.textContent = text;
455        return div.innerHTML;
456    }
457
458    // Update status badge
459    function updateStatusBadge(textarea, errorCount) {
460        let badge = textarea.parentElement.querySelector('.grammar-status-badge');
461        
462        if (!badge) {
463            badge = document.createElement('div');
464            badge.className = 'grammar-status-badge';
465            textarea.parentElement.appendChild(badge);
466        }
467
468        if (errorCount > 0) {
469            badge.className = 'grammar-status-badge has-errors';
470            badge.textContent = `${errorCount} issue${errorCount > 1 ? 's' : ''}`;
471        } else {
472            badge.className = 'grammar-status-badge';
473            badge.textContent = '✓ No issues';
474        }
475
476        // Auto-hide after 3 seconds
477        setTimeout(() => {
478            badge.style.opacity = '0';
479            badge.style.transition = 'opacity 0.3s';
480            setTimeout(() => badge.remove(), 300);
481        }, 3000);
482    }
483
484    // Handle text change
485    async function handleTextChange(textarea) {
486        const text = textarea.value.trim();
487        
488        if (!text || text.length < 3) {
489            // Remove overlay if text is too short
490            const overlay = textarea.parentElement.querySelector('.grammar-overlay');
491            if (overlay) overlay.remove();
492            return;
493        }
494
495        showCheckingIndicator();
496
497        try {
498            const result = await checkGrammar(text);
499            
500            hideCheckingIndicator();
501
502            if (result.hasErrors && result.errors.length > 0) {
503                createOverlay(textarea, result.errors);
504                updateStatusBadge(textarea, result.errors.length);
505                
506                // Store errors for this textarea
507                activeTextAreas.set(textarea, result.errors);
508            } else {
509                // Remove overlay if no errors
510                const overlay = textarea.parentElement.querySelector('.grammar-overlay');
511                if (overlay) overlay.remove();
512                updateStatusBadge(textarea, 0);
513                activeTextAreas.delete(textarea);
514            }
515        } catch (error) {
516            console.error('Grammar check error:', error);
517            hideCheckingIndicator();
518        }
519    }
520
521    // Debounced text change handler
522    const debouncedHandleTextChange = debounce(handleTextChange, 2000);
523
524    // Monitor textarea
525    function monitorTextArea(textarea) {
526        // Skip if already monitoring
527        if (textarea.dataset.grammarMonitored) return;
528        textarea.dataset.grammarMonitored = 'true';
529
530        console.log('Monitoring textarea:', textarea);
531
532        // Check on input with debounce
533        textarea.addEventListener('input', () => {
534            debouncedHandleTextChange(textarea);
535        });
536
537        // Check immediately on focus if there's text
538        textarea.addEventListener('focus', () => {
539            if (textarea.value.trim().length > 3) {
540                setTimeout(() => handleTextChange(textarea), 500);
541            }
542        });
543
544        // Initial check if there's already text
545        if (textarea.value.trim().length > 3) {
546            setTimeout(() => handleTextChange(textarea), 1000);
547        }
548    }
549
550    // Find and monitor all textareas
551    function findAndMonitorTextAreas() {
552        // Find all textareas
553        const textareas = document.querySelectorAll('textarea');
554        textareas.forEach(textarea => {
555            // Skip very small textareas
556            if (textarea.offsetWidth < 100 || textarea.offsetHeight < 50) return;
557            monitorTextArea(textarea);
558        });
559
560        // Find contenteditable elements
561        const editables = document.querySelectorAll('[contenteditable="true"]');
562        editables.forEach(editable => {
563            if (editable.tagName === 'BODY') return;
564            // For now, skip contenteditable - focus on textareas
565        });
566    }
567
568    // Observe DOM for new textareas
569    function observeDOM() {
570        const observer = new MutationObserver(debounce(() => {
571            findAndMonitorTextAreas();
572        }, 500));
573
574        observer.observe(document.body, {
575            childList: true,
576            subtree: true
577        });
578    }
579
580    // Initialize
581    function init() {
582        console.log('AI Grammar Corrector initialized - Real-time checking enabled');
583        
584        // Find existing textareas
585        findAndMonitorTextAreas();
586        
587        // Observe for new textareas
588        observeDOM();
589        
590        // Re-scan periodically for dynamically added textareas
591        setInterval(findAndMonitorTextAreas, 3000);
592    }
593
594    // Start when DOM is ready
595    if (document.readyState === 'loading') {
596        document.addEventListener('DOMContentLoaded', init);
597    } else {
598        init();
599    }
600})();