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})();