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,40 +1,203 @@ | ||
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,9 +206,14 @@ function checkExistingFocusedElement() { | ||
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,24 +221,56 @@ function handleFocusIn(event: FocusEvent) { | ||
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,21 +281,28 @@ function activateElement(element: HTMLElement) { | ||
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,13 +313,21 @@ function activateElement(element: HTMLElement) { | ||
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,19 +343,28 @@ function deactivateElement(element: HTMLElement) { | ||
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,7 +375,29 @@ function handleInput(event: Event) { | ||
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,35 +405,75 @@ function handleInput(event: Event) { | ||
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,75 +484,348 @@ const debouncedCheck = debounce(checkGrammar, 800); | ||
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,8 +834,26 @@ style.textContent = ` | ||
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 | |