feat(core): Update opengrammar/extension/src/content/index.ts (Commit #11)

Swadhin Biswas committed 7 months ago
d043d6d
1 parent 3ab3847
commit d043d6d
1 file changed
+653 -76
Showing 1 changed file+653-76
▼opengrammar/extension/src/content/index.ts
+653-76
1
-import { debounce } from './utils';
2
-import { extractText, getElementFromTarget } from './textExtractor';
3
-import { highlightIssues } from './highlighter';
1
+import { clearHighlights, highlightIssues, isUIActive, refreshFloatingDecorations } from "./highlighter";
2
+import type { AnalysisContext, AutocompleteResponse, Issue } from "../types";
3
+import { extractText, getCaretPosition, getElementFromTarget, setCaretPosition } from "./textExtractor";
4
+import { debounce } from "./utils";
4
5
5
6
interface EditableElement {
6
7
element: HTMLElement;
7
8
observer?: MutationObserver;
8
9
lastText: string;
10
+ lastCheck?: number;
11
+ lastIssues?: Issue[];
9
12
}
10
13
11
14
const activeElements = new Map<HTMLElement, EditableElement>();
12
15
let currentFocusElement: HTMLElement | null = null;
16
+let lastSelectionRange: Range | null = null;
17
+let lastInputSelection: {
18
+ element: HTMLInputElement | HTMLTextAreaElement;
19
+ start: number;
20
+ end: number;
21
+} | null = null;
22
+
23
+// Track which domains are disabled
24
+let disabledDomains: string[] = [];
25
+let checkAsYouTypeEnabled = true;
26
+let showNotificationsEnabled = true;
27
+let autocompleteEnabled = true;
28
+
29
+let autocompleteBox: HTMLElement | null = null;
30
+let autocompleteState: {
31
+ element: HTMLElement;
32
+ suggestion: string;
33
+ replaceStart: number;
34
+ replaceEnd: number;
35
+} | null = null;
36
+
37
+let isContextInvalidated = false;
38
+
39
+/**
40
+ * Check if the extension context is still valid
41
+ */
42
+function checkContext(): boolean {
43
+ if (isContextInvalidated) return false;
44
+
45
+ if (!chrome.runtime?.id) {
46
+ if (!isContextInvalidated) {
47
+ console.warn(
48
+ "[OpenGrammar] Extension context invalidated. Please refresh the page.",
49
+ );
50
+ isContextInvalidated = true;
51
+ deactivateAll();
52
+ }
53
+ return false;
54
+ }
55
+ return true;
56
+}
57
+
58
+/**
59
+ * Deactivate all active elements
60
+ */
61
+function deactivateAll() {
62
+ activeElements.forEach((_, element) => {
63
+ deactivateElement(element);
64
+ });
65
+ activeElements.clear();
66
+}
13
67
14
68
/**
15
69
* Initialize the content script
16
70
*/
17
71
function initialize() {
72
+ console.log("🚀 [OpenGrammar] Content script starting...");
73
+
74
+ // Check context immediately
75
+ if (!checkContext()) return;
76
+
77
+ // Check for file:// protocol
78
+ if (window.location.protocol === "file:") {
79
+ console.warn(
80
+ '⚠️ [OpenGrammar] You are using file:// protocol. Make sure "Allow access to file URLs" is enabled in chrome://extensions for OpenGrammar.',
81
+ );
82
+ }
83
+
84
+ // Load disabled domains
85
+ loadUserSettings();
86
+
18
87
// Listen for focus events on editable elements
19
- document.addEventListener('focusin', handleFocusIn, true);
20
- document.addEventListener('focusout', handleFocusOut, true);
21
-
88
+ document.addEventListener("focusin", handleFocusIn, true);
89
+ document.addEventListener("focusout", handleFocusOut, true);
90
+
22
91
// Handle scroll events to reposition highlights
23
- window.addEventListener('scroll', handleScroll, true);
24
- window.addEventListener('resize', handleScroll, true);
25
-
92
+ window.addEventListener("scroll", debounce(handleScroll, 100), true);
93
+ window.addEventListener("resize", debounce(handleScroll, 100), true);
94
+
26
95
// Check for already focused elements on page load
27
- checkExistingFocusedElement();
28
-
29
- console.log('OpenGrammar content script initialized');
96
+ setTimeout(checkExistingFocusedElement, 500);
97
+
98
+ // Verify connection to background script
99
+ try {
100
+ chrome.runtime.sendMessage({ type: "GET_BACKEND_URL" }, (response) => {
101
+ if (chrome.runtime.lastError) {
102
+ if (chrome.runtime.lastError.message?.includes("context invalidated")) {
103
+ isContextInvalidated = true;
104
+ }
105
+ console.error(
106
+ "❌ [OpenGrammar] Cannot connect to background script:",
107
+ chrome.runtime.lastError.message,
108
+ );
109
+ } else {
110
+ console.log(
111
+ "✅ [OpenGrammar] Connected to background script. Backend URL:",
112
+ response?.url,
113
+ );
114
+ }
115
+ });
116
+ } catch (e) {
117
+ console.error("❌ [OpenGrammar] Fatal error connecting to background:", e);
118
+ isContextInvalidated = true;
119
+ }
120
+
121
+ console.log(
122
+ "[OpenGrammar] Content script initialized on",
123
+ window.location.hostname || "local file",
124
+ );
125
+
126
+ chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => {
127
+ if (request.type === 'GET_SELECTED_TEXT') {
128
+ sendResponse({ text: captureSelectedText() });
129
+ return false;
130
+ }
131
+
132
+ if (request.type === 'GET_ACTIVE_TEXT') {
133
+ const text = currentFocusElement ? extractText(currentFocusElement) : '';
134
+ const issues = currentFocusElement
135
+ ? activeElements.get(currentFocusElement)?.lastIssues || []
136
+ : [];
137
+ sendResponse({ text, issues });
138
+ return false;
139
+ }
140
+
141
+ if (request.type === 'APPLY_REWRITE') {
142
+ const success = applyRewrite(request.original, request.rewritten);
143
+ sendResponse(success);
144
+ return false;
145
+ }
146
+
147
+ return false;
148
+ });
149
+}
150
+
151
+/**
152
+ * Load disabled domains from storage
153
+ */
154
+async function loadUserSettings() {
155
+ if (!checkContext()) return;
156
+
157
+ try {
158
+ const result = await chrome.storage.sync.get([
159
+ "disabledDomains",
160
+ "checkAsYouType",
161
+ "showNotifications",
162
+ "autocompleteEnabled",
163
+ ]);
164
+ disabledDomains = result.disabledDomains || [];
165
+ checkAsYouTypeEnabled = result.checkAsYouType !== false;
166
+ showNotificationsEnabled = result.showNotifications !== false;
167
+ autocompleteEnabled = result.autocompleteEnabled !== false;
168
+ } catch (e) {
169
+ if (e instanceof Error && e.message.includes("context invalidated")) {
170
+ isContextInvalidated = true;
171
+ }
172
+ console.debug("[OpenGrammar] Could not load disabled domains");
173
+ }
174
+}
175
+
176
+/**
177
+ * Check if current domain is disabled
178
+ */
179
+function isDomainDisabled(): boolean {
180
+ if (!checkContext()) return true;
181
+ const domain = window.location.hostname.toLowerCase();
182
+ return disabledDomains.some((d) => domain.includes(d) || d.includes(domain));
30
183
}
31
184
32
185
/**
33
186
* Check for already focused elements on page load
34
187
*/
35
188
function checkExistingFocusedElement() {
189
+ if (!checkContext()) return;
190
+ if (isDomainDisabled()) {
191
+ console.log("[OpenGrammar] Domain is disabled, skipping");
192
+ return;
193
+ }
194
+
36
195
const activeElement = document.activeElement as HTMLElement;
37
196
if (activeElement && isEditable(activeElement)) {
197
+ console.log(
198
+ "[OpenGrammar] Found active element on load:",
199
+ activeElement.tagName,
200
+ );
38
201
activateElement(activeElement);
39
202
}
40
203
}
43
206
* Handle focus in event
44
207
*/
45
208
function handleFocusIn(event: FocusEvent) {
209
+ if (!checkContext()) return;
210
+ if (isDomainDisabled()) return;
211
+
46
212
const target = getElementFromTarget(event.target);
47
- if (target && isEditable(target)) {
48
- activateElement(target);
213
+ const normalizedTarget = normalizeEditableTarget(target);
214
+ if (normalizedTarget && isEditable(normalizedTarget)) {
215
+ console.log("[OpenGrammar] Focus in:", normalizedTarget.tagName, normalizedTarget.className);
216
+ activateElement(normalizedTarget);
49
217
}
50
218
}
51
219
53
221
* Handle focus out event
54
222
*/
55
223
function handleFocusOut(event: FocusEvent) {
224
+ if (!checkContext()) return;
225
+
56
226
const target = getElementFromTarget(event.target);
227
+ const relatedTarget = event.relatedTarget as HTMLElement;
228
+
229
+ // If we're moving focus to something inside our own UI, don't deactivate
230
+ if (
231
+ relatedTarget &&
232
+ (relatedTarget.closest(".opengrammar-tooltip") ||
233
+ relatedTarget.closest(".opengrammar-badge") ||
234
+ relatedTarget.closest(".opengrammar-highlight") ||
235
+ relatedTarget.classList.contains("opengrammar-highlight"))
236
+ ) {
237
+ console.log("[OpenGrammar] Focus moving to UI, staying active");
238
+ return;
239
+ }
240
+
57
241
if (target && activeElements.has(target)) {
58
- // Delay deactivation to allow for new focus
242
+ // Longer timeout to allow clicks on UI to register
59
243
setTimeout(() => {
244
+ if (!checkContext()) return;
245
+
246
+ // Don't deactivate while OpenGrammar UI (tooltip/panel) is active
247
+ if (isUIActive()) return;
248
+
249
+ const currentActive = document.activeElement;
250
+ if (
251
+ currentActive &&
252
+ (currentActive.closest(".opengrammar-tooltip") ||
253
+ currentActive.closest(".opengrammar-badge") ||
254
+ currentActive.closest(".opengrammar-highlight") ||
255
+ currentActive.classList.contains("opengrammar-highlight"))
256
+ ) {
257
+ return;
258
+ }
259
+
60
260
if (document.activeElement !== target) {
261
+ console.log("[OpenGrammar] Focus out:", target.tagName);
61
262
deactivateElement(target);
62
263
}
63
- }, 100);
264
+ }, 200);
64
265
}
65
266
}
66
267
67
268
/**
68
269
* Handle scroll event to update highlight positions
69
270
*/
70
-const handleScroll = debounce(() => {
71
- // Highlights are positioned absolutely, so they should scroll with the page
72
- // This is handled by the highlighter, but we can trigger re-analysis if needed
73
-}, 100);
271
+function handleScroll() {
272
+ refreshFloatingDecorations();
273
+}
74
274
75
275
/**
76
276
* Activate an editable element for grammar checking
81
281
}
82
282
83
283
currentFocusElement = element;
84
-
284
+
85
285
const editableElement: EditableElement = {
86
286
element,
87
287
lastText: extractText(element),
88
288
};
89
289
90
290
// Set up mutation observer for contenteditable elements
91
- if (element.isContentEditable && element.tagName !== 'INPUT' && element.tagName !== 'TEXTAREA') {
92
- const observer = new MutationObserver(debounce(() => {
93
- const newText = extractText(element);
94
- if (newText !== editableElement.lastText) {
95
- editableElement.lastText = newText;
96
- debouncedCheck(element);
97
- }
98
- }, 500));
291
+ if (
292
+ element.isContentEditable &&
293
+ element.tagName !== "INPUT" &&
294
+ element.tagName !== "TEXTAREA"
295
+ ) {
296
+ const observer = new MutationObserver(
297
+ debounce((mutations) => {
298
+ const newText = extractText(element);
299
+ if (newText !== editableElement.lastText) {
300
+ editableElement.lastText = newText;
301
+ console.log("[OpenGrammar] Content changed, checking grammar...");
302
+ debouncedCheck(element);
303
+ }
304
+ }, 500),
305
+ );
99
306
100
307
observer.observe(element, {
101
308
childList: true,
106
313
editableElement.observer = observer;
107
314
}
108
315
109
- // Also listen for input events
110
- element.addEventListener('input', handleInput as EventListener);
111
-
316
+ // Listen for input events
317
+ element.addEventListener("input", handleInput as EventListener);
318
+ element.addEventListener("keydown", handleKeyDown as EventListener);
319
+
112
320
activeElements.set(element, editableElement);
113
-
321
+
114
322
// Initial check
115
- debouncedCheck(element);
323
+ console.log(
324
+ "[OpenGrammar] Activated element, initial text:",
325
+ editableElement.lastText.substring(0, 50),
326
+ );
327
+ void syncActiveContext(editableElement.lastText, editableElement.lastIssues || []);
328
+ if (checkAsYouTypeEnabled) {
329
+ debouncedCheck(element);
330
+ }
116
331
}
117
332
118
333
/**
128
343
}
129
344
130
345
// Remove event listeners
131
- element.removeEventListener('input', handleInput as EventListener);
132
-
133
- // Clear highlights
134
- const existingOverlay = document.getElementById('opengrammar-overlay');
135
- if (existingOverlay) {
136
- existingOverlay.remove();
346
+ element.removeEventListener("input", handleInput as EventListener);
347
+ element.removeEventListener("keydown", handleKeyDown as EventListener);
348
+
349
+ // Clear highlights only if NOT currently interacting with a tooltip
350
+ const currentActive = document.activeElement;
351
+ const isInteractingWithUI =
352
+ currentActive &&
353
+ (currentActive.closest(".opengrammar-tooltip") ||
354
+ currentActive.closest(".opengrammar-badge"));
355
+
356
+ if (!isInteractingWithUI && !isUIActive()) {
357
+ clearHighlights();
137
358
}
138
359
139
360
activeElements.delete(element);
140
-
361
+
141
362
if (currentFocusElement === element) {
142
363
currentFocusElement = null;
143
364
}
365
+
366
+ console.log("[OpenGrammar] Deactivated element");
367
+ hideAutocomplete();
144
368
}
145
369
146
370
/**
151
375
if (target && activeElements.has(target)) {
152
376
const editableElement = activeElements.get(target)!;
153
377
editableElement.lastText = extractText(target);
154
- debouncedCheck(target);
378
+ void syncActiveContext(editableElement.lastText, editableElement.lastIssues || []);
379
+ if (checkAsYouTypeEnabled) {
380
+ debouncedCheck(target);
381
+ }
382
+ if (autocompleteEnabled) {
383
+ debouncedAutocomplete(target);
384
+ }
385
+ }
386
+}
387
+
388
+function handleKeyDown(event: Event) {
389
+ const keyboardEvent = event as KeyboardEvent;
390
+ const target = getElementFromTarget(event.target);
391
+ if (!target) return;
392
+
393
+ if (keyboardEvent.key === 'Tab' && autocompleteState && autocompleteState.element === target) {
394
+ keyboardEvent.preventDefault();
395
+ acceptAutocomplete();
396
+ return;
397
+ }
398
+
399
+ if (keyboardEvent.key === 'Escape') {
400
+ hideAutocomplete();
155
401
}
156
402
}
157
403
159
405
* Check grammar for an element
160
406
*/
161
407
const checkGrammar = async (element: HTMLElement) => {
162
- if (!activeElements.has(element)) return;
408
+ if (!checkContext()) return;
409
+
410
+ if (!activeElements.has(element)) {
411
+ console.log("[OpenGrammar] Element not active, skipping check");
412
+ return;
413
+ }
163
414
164
415
const text = extractText(element);
165
416
417
+ console.log(
418
+ "[OpenGrammar] Checking grammar for text:",
419
+ text.substring(0, 100),
420
+ );
421
+
166
422
// Skip very short text
167
423
if (!text || text.trim().length < 5) {
168
- // Clear highlights for short text
169
- const existingOverlay = document.getElementById('opengrammar-overlay');
170
- if (existingOverlay) existingOverlay.remove();
424
+ console.log("[OpenGrammar] Text too short, skipping");
425
+ clearHighlights();
171
426
return;
172
427
}
173
428
174
429
try {
430
+ const startTime = Date.now();
175
431
const response = await chrome.runtime.sendMessage({
176
- type: 'CHECK_GRAMMAR',
432
+ type: "CHECK_GRAMMAR",
177
433
text,
434
+ context: buildAnalysisContext(element, text),
178
435
});
179
436
437
+ const duration = Date.now() - startTime;
438
+ console.log("[OpenGrammar] Grammar check took:", duration, "ms");
439
+
180
440
if (response?.error) {
181
- console.warn('Grammar check error:', response.error);
441
+ console.warn("[OpenGrammar] Grammar check error:", response.error);
442
+ showNotification("Grammar check failed: " + response.error, "error");
182
443
return;
183
444
}
184
445
185
446
if (response?.issues) {
447
+ console.log("[OpenGrammar] Found", response.issues.length, "issues");
448
+ if (response.issues.length > 0) {
449
+ console.log("[OpenGrammar] First issue:", response.issues[0]);
450
+ }
451
+ const editableElement = activeElements.get(element);
452
+ if (editableElement) {
453
+ editableElement.lastIssues = response.issues;
454
+ }
455
+ void syncActiveContext(text, response.issues);
186
456
highlightIssues(element, response.issues);
457
+ } else {
458
+ console.log("[OpenGrammar] No issues found or empty response");
459
+ const editableElement = activeElements.get(element);
460
+ if (editableElement) {
461
+ editableElement.lastIssues = [];
462
+ }
463
+ void syncActiveContext(text, []);
187
464
}
188
465
} catch (err) {
189
- // Silently handle errors - don't crash the extension
190
- console.debug('Grammar check skipped:', err instanceof Error ? err.message : err);
466
+ if (err instanceof Error && err.message.includes("context invalidated")) {
467
+ isContextInvalidated = true;
468
+ deactivateAll();
469
+ return;
470
+ }
471
+
472
+ console.error("[OpenGrammar] Grammar check failed:", err);
473
+ showNotification(
474
+ "Cannot connect to grammar service. Make sure the backend is running.",
475
+ "error",
476
+ );
191
477
}
192
478
};
193
479
198
484
*/
199
485
function isEditable(el: HTMLElement): boolean {
200
486
if (!el) return false;
201
-
487
+
202
488
// Check for input/textarea
203
- if (el.tagName === 'INPUT' && !['button', 'submit', 'reset', 'image', 'checkbox', 'radio'].includes((el as HTMLInputElement).type)) {
204
- return true;
489
+ if (el.tagName === "INPUT") {
490
+ const inputType = (el as HTMLInputElement).type.toLowerCase();
491
+ if (
492
+ ![
493
+ "button",
494
+ "submit",
495
+ "reset",
496
+ "image",
497
+ "checkbox",
498
+ "radio",
499
+ "file",
500
+ "hidden",
501
+ ].includes(inputType)
502
+ ) {
503
+ return true;
504
+ }
205
505
}
206
- if (el.tagName === 'TEXTAREA') {
506
+
507
+ if (el.tagName === "TEXTAREA") {
207
508
return true;
208
509
}
209
-
510
+
210
511
// Check for contenteditable
211
512
if (el.isContentEditable) {
212
513
return true;
213
514
}
214
-
515
+
215
516
// Check for role attribute indicating editability
216
- const role = el.getAttribute('role');
217
- if (role === 'textbox') {
517
+ const role = el.getAttribute("role");
518
+ if (role === "textbox") {
218
519
return true;
219
520
}
220
-
521
+
221
522
return false;
222
523
}
223
524
224
525
/**
225
526
* Show a notification to the user
226
527
*/
227
-function showNotification(message: string, type: 'error' | 'warning' | 'info') {
528
+function showNotification(message: string, type: "error" | "warning" | "info") {
529
+ if (!showNotificationsEnabled) return;
530
+
228
531
// Remove existing notifications
229
- const existing = document.getElementById('opengrammar-notification');
532
+ const existing = document.getElementById("opengrammar-notification");
230
533
if (existing) existing.remove();
231
-
232
- const notification = document.createElement('div');
233
- notification.id = 'opengrammar-notification';
534
+
535
+ const notification = document.createElement("div");
536
+ notification.id = "opengrammar-notification";
234
537
notification.style.cssText = `
235
538
position: fixed;
236
539
bottom: 20px;
237
540
right: 20px;
238
541
max-width: 400px;
239
- padding: 12px 16px;
240
- background: ${type === 'error' ? '#dc2626' : type === 'warning' ? '#f59e0b' : '#2563eb'};
542
+ padding: 14px 18px;
543
+ background: ${type === "error" ? "#dc2626" : type === "warning" ? "#f59e0b" : "#2563eb"};
241
544
color: white;
242
545
border-radius: 8px;
243
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
244
- z-index: 100002;
546
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
547
+ z-index: 2147483647;
245
548
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
246
549
font-size: 14px;
247
550
line-height: 1.5;
248
551
animation: opengrammar-slide-in 0.3s ease-out;
249
552
`;
250
-
553
+
251
554
notification.textContent = message;
252
555
document.body.appendChild(notification);
253
-
556
+
254
557
// Auto-remove after 5 seconds
255
558
setTimeout(() => {
256
- notification.style.opacity = '0';
257
- notification.style.transition = 'opacity 0.3s';
559
+ notification.style.opacity = "0";
560
+ notification.style.transition = "opacity 0.3s";
258
561
setTimeout(() => notification.remove(), 300);
259
562
}, 5000);
260
563
}
261
564
565
+async function syncActiveContext(text: string, issues: Issue[]) {
566
+ if (!checkContext()) return;
567
+
568
+ try {
569
+ await chrome.runtime.sendMessage({
570
+ type: 'SYNC_ACTIVE_CONTEXT',
571
+ text,
572
+ issues,
573
+ });
574
+ } catch {
575
+ // ignore sync failures
576
+ }
577
+}
578
+
579
+function normalizeEditableTarget(target: HTMLElement | null): HTMLElement | null {
580
+ if (!target) return null;
581
+ if (isEditable(target)) return target;
582
+
583
+ const closest = target.closest('input, textarea, [contenteditable="true"], [role="textbox"]') as HTMLElement | null;
584
+ if (closest) return closest;
585
+
586
+ if (window.location.hostname.includes('docs.google.com')) {
587
+ return target.closest('.kix-appview-editor, [role="textbox"]') as HTMLElement | null;
588
+ }
589
+
590
+ return target;
591
+}
592
+
593
+function buildAnalysisContext(element: HTMLElement, text: string): AnalysisContext {
594
+ const cursor = getCaretPosition(element);
595
+ const previousText = text.slice(Math.max(0, cursor - 220), cursor).trim();
596
+ const nextText = text.slice(cursor, Math.min(text.length, cursor + 220)).trim();
597
+ const activeSentence = text.slice(
598
+ Math.max(0, text.lastIndexOf('.', cursor - 1) + 1),
599
+ Math.min(text.length, (() => {
600
+ const nextPeriod = text.indexOf('.', cursor);
601
+ return nextPeriod === -1 ? text.length : nextPeriod + 1;
602
+ })()),
603
+ ).trim();
604
+
605
+ return {
606
+ domain: window.location.hostname,
607
+ editorType: getEditorType(element),
608
+ activeSentence,
609
+ previousText,
610
+ nextText,
611
+ fullTextExcerpt: text.slice(0, 1500),
612
+ };
613
+}
614
+
615
+function getEditorType(element: HTMLElement): string {
616
+ if (window.location.hostname.includes('docs.google.com')) return 'google-docs';
617
+ if (element.tagName === 'TEXTAREA') return 'textarea';
618
+ if (element.tagName === 'INPUT') return 'input';
619
+ if (element.isContentEditable) return 'contenteditable';
620
+ return 'generic';
621
+}
622
+
623
+function captureSelectedText(): string {
624
+ const activeElement = currentFocusElement;
625
+
626
+ if (
627
+ activeElement &&
628
+ (activeElement.tagName === 'INPUT' || activeElement.tagName === 'TEXTAREA')
629
+ ) {
630
+ const input = activeElement as HTMLInputElement | HTMLTextAreaElement;
631
+ const start = input.selectionStart ?? 0;
632
+ const end = input.selectionEnd ?? start;
633
+ lastInputSelection = { element: input, start, end };
634
+ return input.value.slice(start, end);
635
+ }
636
+
637
+ const selection = window.getSelection();
638
+ if (!selection || selection.rangeCount === 0) {
639
+ return '';
640
+ }
641
+
642
+ const text = selection.toString();
643
+ if (!text) {
644
+ return '';
645
+ }
646
+
647
+ lastSelectionRange = selection.getRangeAt(0).cloneRange();
648
+ return text;
649
+}
650
+
651
+function applyRewrite(original: string, rewritten: string): { success: boolean; error?: string } {
652
+ if (
653
+ lastInputSelection &&
654
+ document.contains(lastInputSelection.element) &&
655
+ lastInputSelection.end >= lastInputSelection.start
656
+ ) {
657
+ const { element, start, end } = lastInputSelection;
658
+ const selected = element.value.slice(start, end);
659
+ if (!selected || selected === original) {
660
+ const before = element.value.slice(0, start);
661
+ const after = element.value.slice(end);
662
+ element.value = `${before}${rewritten}${after}`;
663
+ const nextCaret = start + rewritten.length;
664
+ element.setSelectionRange(nextCaret, nextCaret);
665
+ element.focus();
666
+ element.dispatchEvent(new Event('input', { bubbles: true }));
667
+ return { success: true };
668
+ }
669
+ }
670
+
671
+ if (lastSelectionRange) {
672
+ try {
673
+ const selection = window.getSelection();
674
+ selection?.removeAllRanges();
675
+ selection?.addRange(lastSelectionRange);
676
+ const activeText = selection?.toString() || '';
677
+ if (!activeText || activeText === original) {
678
+ lastSelectionRange.deleteContents();
679
+ lastSelectionRange.insertNode(document.createTextNode(rewritten));
680
+ currentFocusElement?.dispatchEvent(new Event('input', { bubbles: true }));
681
+ selection?.removeAllRanges();
682
+ lastSelectionRange = null;
683
+ return { success: true };
684
+ }
685
+ } catch (error) {
686
+ console.debug('[OpenGrammar] Failed to apply selection rewrite:', error);
687
+ }
688
+ }
689
+
690
+ if (currentFocusElement) {
691
+ const text = extractText(currentFocusElement);
692
+ const index = text.indexOf(original);
693
+ if (index !== -1) {
694
+ if (
695
+ currentFocusElement.tagName === 'INPUT' ||
696
+ currentFocusElement.tagName === 'TEXTAREA'
697
+ ) {
698
+ const input = currentFocusElement as HTMLInputElement | HTMLTextAreaElement;
699
+ input.value = `${text.slice(0, index)}${rewritten}${text.slice(index + original.length)}`;
700
+ input.dispatchEvent(new Event('input', { bubbles: true }));
701
+ return { success: true };
702
+ }
703
+
704
+ currentFocusElement.textContent = `${text.slice(0, index)}${rewritten}${text.slice(index + original.length)}`;
705
+ currentFocusElement.dispatchEvent(new Event('input', { bubbles: true }));
706
+ return { success: true };
707
+ }
708
+ }
709
+
710
+ return { success: false, error: 'No editable selection available to rewrite.' };
711
+}
712
+
713
+const requestAutocomplete = async (element: HTMLElement) => {
714
+ if (!checkContext() || !autocompleteEnabled) return;
715
+ if (currentFocusElement !== element) return;
716
+
717
+ const text = extractText(element);
718
+ if (!text || text.trim().length < 12) {
719
+ hideAutocomplete();
720
+ return;
721
+ }
722
+
723
+ const cursor = getCaretPosition(element);
724
+ if (cursor < text.length && !element.isContentEditable) {
725
+ hideAutocomplete();
726
+ return;
727
+ }
728
+
729
+ try {
730
+ const response = await chrome.runtime.sendMessage({
731
+ type: 'AUTOCOMPLETE_TEXT',
732
+ text,
733
+ cursor,
734
+ context: buildAnalysisContext(element, text),
735
+ }) as AutocompleteResponse;
736
+
737
+ if (!response?.suggestion || response.confidence < 0.35) {
738
+ hideAutocomplete();
739
+ return;
740
+ }
741
+
742
+ showAutocomplete(element, response);
743
+ } catch {
744
+ hideAutocomplete();
745
+ }
746
+};
747
+
748
+const debouncedAutocomplete = debounce(requestAutocomplete, 700);
749
+
750
+function showAutocomplete(element: HTMLElement, response: AutocompleteResponse) {
751
+ hideAutocomplete();
752
+
753
+ const rect = element.getBoundingClientRect();
754
+ const box = document.createElement('div');
755
+ box.className = 'opengrammar-autocomplete';
756
+ box.style.cssText = `
757
+ position: fixed;
758
+ left: ${Math.max(12, rect.left + 12)}px;
759
+ top: ${Math.min(window.innerHeight - 70, rect.bottom + 8)}px;
760
+ max-width: min(460px, calc(100vw - 24px));
761
+ padding: 10px 12px;
762
+ background: rgba(17, 24, 39, 0.96);
763
+ color: white;
764
+ border-radius: 10px;
765
+ font-size: 13px;
766
+ line-height: 1.4;
767
+ z-index: 2147483647;
768
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.24);
769
+ `;
770
+ box.innerHTML = `<strong style="color:#93c5fd;">Suggestion</strong> ${escapeHtml(response.suggestion)} <span style="display:block;color:#cbd5e1;margin-top:4px;">Press Tab to accept · ${Math.round(response.confidence * 100)}% confidence</span>`;
771
+
772
+ document.body.appendChild(box);
773
+ autocompleteBox = box;
774
+ autocompleteState = {
775
+ element,
776
+ suggestion: response.suggestion,
777
+ replaceStart: response.replaceStart,
778
+ replaceEnd: response.replaceEnd,
779
+ };
780
+}
781
+
782
+function acceptAutocomplete() {
783
+ if (!autocompleteState) return;
784
+
785
+ const { element, suggestion, replaceStart, replaceEnd } = autocompleteState;
786
+ const text = extractText(element);
787
+
788
+ if (element.tagName === 'INPUT' || element.tagName === 'TEXTAREA') {
789
+ const input = element as HTMLInputElement | HTMLTextAreaElement;
790
+ input.value = `${text.slice(0, replaceStart)}${suggestion}${text.slice(replaceEnd)}`;
791
+ const nextCursor = replaceStart + suggestion.length;
792
+ input.setSelectionRange(nextCursor, nextCursor);
793
+ input.dispatchEvent(new Event('input', { bubbles: true }));
794
+ } else {
795
+ element.textContent = `${text.slice(0, replaceStart)}${suggestion}${text.slice(replaceEnd)}`;
796
+ setCaretPosition(element, replaceStart + suggestion.length);
797
+ element.dispatchEvent(new Event('input', { bubbles: true }));
798
+ }
799
+
800
+ void chrome.runtime.sendMessage({
801
+ type: 'TRACK_ANALYTICS_EVENT',
802
+ eventType: 'autocomplete_accepted',
803
+ payload: { count: 1, domain: window.location.hostname },
804
+ });
805
+
806
+ hideAutocomplete();
807
+}
808
+
809
+function hideAutocomplete() {
810
+ autocompleteBox?.remove();
811
+ autocompleteBox = null;
812
+ autocompleteState = null;
813
+}
814
+
815
+function escapeHtml(text: string): string {
816
+ const div = document.createElement('div');
817
+ div.textContent = text;
818
+ return div.innerHTML;
819
+}
820
+
262
821
// Add CSS animations
263
-const style = document.createElement('style');
822
+const style = document.createElement("style");
264
823
style.textContent = `
265
824
@keyframes opengrammar-fade-in {
266
825
from { opacity: 0; transform: translateY(-4px); }
267
826
to { opacity: 1; transform: translateY(0); }
268
827
}
269
-
828
+
270
829
@keyframes opengrammar-slide-in {
271
830
from { opacity: 0; transform: translateX(100%); }
272
831
to { opacity: 1; transform: translateX(0); }
275
834
document.head.appendChild(style);
276
835
277
836
// Initialize on DOM ready
278
-if (document.readyState === 'loading') {
279
- document.addEventListener('DOMContentLoaded', initialize);
837
+if (document.readyState === "loading") {
838
+ document.addEventListener("DOMContentLoaded", initialize);
280
839
} else {
281
840
initialize();
282
841
}
842
+
843
+// Listen for storage changes (e.g., when domain is disabled/enabled)
844
+chrome.storage?.onChanged?.addListener((changes) => {
845
+ if (changes.disabledDomains) {
846
+ disabledDomains = changes.disabledDomains.newValue || [];
847
+ console.log("[OpenGrammar] Disabled domains updated:", disabledDomains);
848
+ }
849
+ if (changes.checkAsYouType) {
850
+ checkAsYouTypeEnabled = changes.checkAsYouType.newValue !== false;
851
+ }
852
+ if (changes.showNotifications) {
853
+ showNotificationsEnabled = changes.showNotifications.newValue !== false;
854
+ }
855
+ if (changes.autocompleteEnabled) {
856
+ autocompleteEnabled = changes.autocompleteEnabled.newValue !== false;
857
+ if (!autocompleteEnabled) hideAutocomplete();
858
+ }
859
+});
283
860