feat(core): Update opengrammar/extension/src/content/highlighter.ts (Commit #10)

Swadhin Biswas committed 7 months ago
3ab3847
1 parent 164aa5c
commit 3ab3847
1 file changed
+981 -221
▼opengrammar/extension/src/content/highlighter.ts
+981-221
1
-import { Issue } from '../types';
1
+import type { IgnoredIssue, Issue } from "../types";
2
2
3
3
let currentTooltip: HTMLElement | null = null;
4
+let highlightContainer: HTMLElement | null = null;
5
+let assistantBubble: HTMLElement | null = null;
6
+let inputMirrorOverlay: HTMLElement | null = null;
7
+let inputMirrorContent: HTMLElement | null = null;
8
+let inputMirrorTarget: HTMLElement | null = null;
9
+let cleanupInputMirror: (() => void) | null = null;
10
+let uiActive = false;
4
11
5
12
/**
6
- * Grammarly-style highlighter
7
- * Shows underlines and clickable popups with suggestions
13
+ * Returns true when OpenGrammar UI (tooltip/panel) is actively shown.
14
+ * Used by content script to prevent deactivating the target element.
15
+ */
16
+export function isUIActive(): boolean {
17
+ return uiActive;
18
+}
19
+
20
+/**
21
+ * Initialize the highlight overlay system
22
+ */
23
+function initHighlightContainer() {
24
+ if (!highlightContainer) {
25
+ highlightContainer = document.createElement("div");
26
+ highlightContainer.id = "opengrammar-highlights";
27
+ highlightContainer.style.cssText = `
28
+ position: fixed;
29
+ top: 0;
30
+ left: 0;
31
+ width: 100%;
32
+ height: 100%;
33
+ pointer-events: none;
34
+ z-index: 2147483647;
35
+ `;
36
+ document.body.appendChild(highlightContainer);
37
+ }
38
+}
39
+
40
+/**
41
+ * Grammarly-style highlighter with proper wavy underlines
8
42
*/
9
43
export function highlightIssues(element: HTMLElement, issues: Issue[]) {
44
+ initHighlightContainer();
45
+
10
46
// Clear existing highlights
11
47
clearHighlights();
12
48
13
49
if (issues.length === 0) return;
14
50
15
- // For input/textarea, show badge instead of inline highlights
16
- if (element.tagName === 'INPUT' || element.tagName === 'TEXTAREA') {
51
+ showAssistantBubble(element, issues);
52
+
53
+ // For input/textarea, we can't show inline highlights
54
+ // Show a mirrored overlay + badge on the element instead
55
+ if (element.tagName === "INPUT" || element.tagName === "TEXTAREA") {
56
+ showInputMirror(element, issues);
17
57
showInputBadge(element, issues);
18
58
return;
19
59
}
20
60
21
- // For contenteditable, create inline highlights
61
+ // For contenteditable elements, create inline highlights by wrapping text
22
62
issues.forEach((issue, issueIndex) => {
23
63
if (issue.ignored) return;
24
-
25
64
try {
26
- const range = findTextRange(element, issue.original, issue.offset);
27
- if (range) {
28
- createHighlight(range, issue, element, issueIndex);
29
- }
65
+ wrapTextWithHighlight(element, issue, issueIndex);
30
66
} catch (e) {
31
- console.debug('Could not create highlight:', issue, e);
67
+ console.debug("Could not create highlight for issue:", issue, e);
32
68
}
33
69
});
34
70
}
35
71
36
72
/**
37
- * Clear all OpenGrammar highlights and tooltips
73
+ * Wrap text nodes with highlighted spans - Grammarly's actual approach
38
74
*/
39
-export function clearHighlights() {
40
- // Remove all highlights
41
- document.querySelectorAll('.opengrammar-highlight').forEach(el => el.remove());
42
- // Remove all tooltips
43
- document.querySelectorAll('.opengrammar-tooltip').forEach(el => el.remove());
44
- currentTooltip = null;
75
+function wrapTextWithHighlight(
76
+ root: HTMLElement,
77
+ issue: Issue,
78
+ issueIndex: number,
79
+) {
80
+ const textNodes: Node[] = [];
81
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
82
+
83
+ let node: Node | null;
84
+ while ((node = walker.nextNode())) {
85
+ textNodes.push(node);
86
+ }
87
+
88
+ let charCount = 0;
89
+
90
+ for (const textNode of textNodes) {
91
+ const nodeLength = textNode.textContent?.length || 0;
92
+ const nodeStart = charCount;
93
+ const nodeEnd = charCount + nodeLength;
94
+
95
+ // Check if issue overlaps with this text node
96
+ if (issue.offset < nodeEnd && issue.offset + issue.length > nodeStart) {
97
+ const startInNode = Math.max(0, issue.offset - nodeStart);
98
+ const endInNode = Math.min(
99
+ nodeLength,
100
+ issue.offset + issue.length - nodeStart,
101
+ );
102
+
103
+ if (startInNode < endInNode && textNode.parentNode) {
104
+ const range = document.createRange();
105
+ range.setStart(textNode, startInNode);
106
+ range.setEnd(textNode, endInNode);
107
+
108
+ const span = document.createElement("span");
109
+ span.className = "opengrammar-highlight";
110
+ span.style.cssText = `
111
+ background: ${getInlineBackground(issue.type)};
112
+ border-bottom: 2px wavy ${getColor(issue.type)};
113
+ cursor: pointer;
114
+ padding-bottom: 1px;
115
+ border-radius: 2px;
116
+ box-shadow: inset 0 -1px 0 rgba(255,255,255,0.45);
117
+ `;
118
+ span.dataset.issue = JSON.stringify(issue);
119
+ span.dataset.index = issueIndex.toString();
120
+
121
+ // Add hover effect
122
+ span.addEventListener("mouseenter", (e) => {
123
+ e.stopPropagation();
124
+ span.style.background = getHoverBackground(issue.type);
125
+ });
126
+ span.addEventListener("mouseleave", (e) => {
127
+ e.stopPropagation();
128
+ span.style.background = "transparent";
129
+ });
130
+
131
+ // Click to show tooltip
132
+ span.addEventListener("click", (e) => {
133
+ e.stopPropagation();
134
+ e.preventDefault();
135
+ showTooltip(span, issue, root);
136
+ });
137
+
138
+ try {
139
+ range.surroundContents(span);
140
+ } catch (e) {
141
+ // If surrounding fails, try alternative approach
142
+ console.debug("surroundContents failed, using alternative:", e);
143
+ const text = textNode.textContent || "";
144
+ const before = text.substring(0, startInNode);
145
+ const middle = text.substring(startInNode, endInNode);
146
+ const after = text.substring(endInNode);
147
+
148
+ const parent = textNode.parentNode;
149
+ if (parent) {
150
+ const frag = document.createDocumentFragment();
151
+ if (before) frag.appendChild(document.createTextNode(before));
152
+
153
+ const highlightSpan = document.createElement("span");
154
+ highlightSpan.className = "opengrammar-highlight";
155
+ highlightSpan.style.cssText = `
156
+ background: ${getInlineBackground(issue.type)};
157
+ border-bottom: 2px wavy ${getColor(issue.type)};
158
+ cursor: pointer;
159
+ padding-bottom: 1px;
160
+ border-radius: 2px;
161
+ `;
162
+ highlightSpan.textContent = middle;
163
+ highlightSpan.dataset.issue = JSON.stringify(issue);
164
+
165
+ highlightSpan.addEventListener("mouseenter", () => {
166
+ highlightSpan.style.background = getHoverBackground(issue.type);
167
+ });
168
+ highlightSpan.addEventListener("mouseleave", () => {
169
+ highlightSpan.style.background = "transparent";
170
+ });
171
+ highlightSpan.addEventListener("click", (e) => {
172
+ e.stopPropagation();
173
+ e.preventDefault();
174
+ showTooltip(highlightSpan, issue, root);
175
+ });
176
+
177
+ frag.appendChild(highlightSpan);
178
+ if (after) frag.appendChild(document.createTextNode(after));
179
+
180
+ parent.replaceChild(frag, textNode);
181
+ }
182
+ }
183
+ }
184
+ }
185
+
186
+ charCount = nodeEnd;
187
+ }
188
+}
189
+
190
+function getHoverBackground(type: string): string {
191
+ switch (type) {
192
+ case "grammar":
193
+ case "spelling":
194
+ return "rgba(239, 68, 68, 0.1)";
195
+ case "clarity":
196
+ return "rgba(245, 158, 11, 0.1)";
197
+ case "style":
198
+ return "rgba(59, 130, 246, 0.1)";
199
+ default:
200
+ return "rgba(239, 68, 68, 0.1)";
201
+ }
202
+}
203
+
204
+function getInlineBackground(type: string): string {
205
+ switch (type) {
206
+ case "grammar":
207
+ case "spelling":
208
+ return "rgba(239, 68, 68, 0.12)";
209
+ case "clarity":
210
+ return "rgba(245, 158, 11, 0.14)";
211
+ case "style":
212
+ return "rgba(59, 130, 246, 0.12)";
213
+ default:
214
+ return "rgba(239, 68, 68, 0.1)";
215
+ }
45
216
}
46
217
47
218
/**
48
- * Create a Grammarly-style underline highlight
219
+ * Clear all OpenGrammar highlights and tooltips
49
220
*/
50
-function createHighlight(range: Range, issue: Issue, element: HTMLElement, issueIndex: number) {
51
- const rects = range.getClientRects();
52
-
53
- for (let i = 0; i < rects.length; i++) {
54
- const rect = rects[i];
55
-
56
- // Create underline
57
- const underline = document.createElement('div');
58
- underline.className = 'opengrammar-highlight';
59
- underline.style.cssText = `
60
- position: absolute;
61
- left: ${rect.left + window.scrollX}px;
62
- top: ${rect.bottom + window.scrollY - 2}px;
63
- width: ${rect.width}px;
64
- height: 3px;
65
- background-color: ${getColor(issue.type)};
66
- cursor: pointer;
67
- border-radius: 2px;
68
- z-index: 10000;
69
- transition: opacity 0.2s;
70
- `;
71
-
72
- // Store issue data
73
- (underline as any).__opengrammar_issue = issue;
74
- (underline as any).__opengrammar_element = element;
75
-
76
- // Click to show tooltip
77
- underline.addEventListener('click', (e) => {
78
- e.stopPropagation();
79
- showTooltip(underline, issue, element);
80
- });
81
-
82
- // Hover to show tooltip (after 500ms)
83
- let hoverTimeout: any;
84
- underline.addEventListener('mouseenter', () => {
85
- hoverTimeout = setTimeout(() => {
86
- showTooltip(underline, issue, element);
87
- }, 500);
88
- });
89
- underline.addEventListener('mouseleave', () => {
90
- if (hoverTimeout) clearTimeout(hoverTimeout);
221
+export function clearHighlights() {
222
+ // Remove all highlights and success-animation spans from the page
223
+ document
224
+ .querySelectorAll(".opengrammar-highlight, .opengrammar-success")
225
+ .forEach((el) => {
226
+ const parent = el.parentNode;
227
+ if (parent) {
228
+ parent.replaceChild(document.createTextNode(el.textContent || ""), el);
229
+ parent.normalize();
230
+ }
91
231
});
92
-
93
- document.body.appendChild(underline);
232
+
233
+ // Remove badge
234
+ document.querySelectorAll(".opengrammar-badge").forEach((el) => el.remove());
235
+
236
+ destroyInputMirror();
237
+
238
+ if (assistantBubble) {
239
+ assistantBubble.remove();
240
+ assistantBubble = null;
241
+ }
242
+
243
+ // Remove tooltip
244
+ if (currentTooltip) {
245
+ currentTooltip.remove();
246
+ currentTooltip = null;
247
+ }
248
+ uiActive = false;
249
+}
250
+
251
+export function refreshFloatingDecorations() {
252
+ if (assistantBubble && inputMirrorTarget) {
253
+ const summary = assistantBubble.querySelector('[data-og-summary="true"]') as HTMLElement | null;
254
+ if (summary && assistantBubble.dataset.issueSummary) {
255
+ summary.textContent = assistantBubble.dataset.issueSummary;
256
+ }
257
+ }
258
+
259
+ if (inputMirrorOverlay && inputMirrorTarget) {
260
+ positionInputMirror(inputMirrorTarget);
261
+ syncInputMirrorScroll(inputMirrorTarget);
262
+ }
263
+
264
+ document.querySelectorAll(".opengrammar-badge").forEach((badge) => {
265
+ const target = inputMirrorTarget;
266
+ if (!target) return;
267
+ const rect = target.getBoundingClientRect();
268
+ (badge as HTMLElement).style.left = `${rect.right - 35}px`;
269
+ (badge as HTMLElement).style.top = `${rect.top + 5}px`;
270
+ });
271
+}
272
+
273
+function showAssistantBubble(element: HTMLElement, issues: Issue[]) {
274
+ if (assistantBubble) {
275
+ assistantBubble.remove();
276
+ assistantBubble = null;
94
277
}
278
+
279
+ const summary = summarizeIssues(issues);
280
+ const bubble = document.createElement("button");
281
+ bubble.className = "opengrammar-assistant";
282
+ bubble.type = "button";
283
+ bubble.style.cssText = `
284
+ position: fixed;
285
+ right: 16px;
286
+ display: inline-flex;
287
+ align-items: center;
288
+ justify-content: center;
289
+ width: 40px;
290
+ height: 40px;
291
+ padding: 0;
292
+ padding: 10px 12px;
293
+ border-radius: 999px;
294
+ background: rgba(17, 24, 39, 0.94);
295
+ background: rgba(17, 24, 39, 0.96);
296
+ box-shadow: 0 10px 24px rgba(15, 23, 42, 0.22);
297
+ box-shadow: 0 10px 28px rgba(15, 23, 42, 0.26);
298
+ z-index: 2147483646;
299
+ cursor: pointer;
300
+ pointer-events: auto;
301
+ text-align: left;
302
+ backdrop-filter: blur(10px);
303
+ `;
304
+
305
+ <span style="display:inline-flex;align-items:center;justify-content:center;width:30px;height:30px;border-radius:999px;background:linear-gradient(135deg,#6d5efc,#1d4ed8);font-size:15px;font-weight:700;box-shadow:inset 0 1px 0 rgba(255,255,255,0.2);">G</span>
306
+ <span data-og-count="true" style="position:absolute;top:-2px;right:-2px;min-width:18px;height:18px;padding:0 5px;border-radius:999px;background:#ef4444;color:white;font-size:10px;font-weight:700;display:inline-flex;align-items:center;justify-content:center;box-shadow:0 4px 10px rgba(239,68,68,0.35);">${issues.length}</span>
307
+ </span>
308
+ `;
309
+ bubble.title = summary || `${issues.length} issue${issues.length === 1 ? '' : 's'} found`;
310
+ bubble.dataset.issueSummary = summary || 'Click to review suggestions';
311
+
312
+ bubble.addEventListener("mouseenter", () => {
313
+ const count = assistantBubble.querySelector('[data-og-count="true"]') as HTMLElement | null;
314
+ if (count && assistantBubble.dataset.issueCount) {
315
+ count.textContent = assistantBubble.dataset.issueCount;
316
+ }
317
+ bubble.style.transform = "translateY(0) scale(1)";
318
+ });
319
+
320
+ bubble.dataset.issueCount = String(issues.length);
321
+ bubble.addEventListener("mousedown", (e) => {
322
+ e.preventDefault();
323
+ });
324
+
325
+ bubble.addEventListener("click", (e) => {
326
+ e.preventDefault();
327
+ e.stopPropagation();
328
+ if (element.tagName === "INPUT" || element.tagName === "TEXTAREA") {
329
+ showIssuePanel(bubble, issues, element, 0, () => undefined);
330
+ return;
331
+ }
332
+
333
+ const firstHighlight = document.querySelector(".opengrammar-highlight") as HTMLElement | null;
334
+ if (firstHighlight) {
335
+ showTooltip(firstHighlight, issues[0]!, element);
336
+ }
337
+ });
338
+
339
+ document.body.appendChild(bubble);
340
+ assistantBubble = bubble;
341
+}
342
+
343
+function summarizeIssues(issues: Issue[]): string {
344
+ const counts = issues.reduce<Record<string, number>>((acc, issue) => {
345
+ acc[issue.type] = (acc[issue.type] || 0) + 1;
346
+ return acc;
347
+ }, {});
348
+
349
+ const ordered: Array<Issue["type"]> = ["grammar", "spelling", "clarity", "style"];
350
+ return ordered
351
+ .filter((type) => counts[type])
352
+ .map((type) => `${counts[type]} ${type}`)
353
+ .join(" • ");
354
+}
355
+
356
+function showInputMirror(element: HTMLElement, issues: Issue[]) {
357
+ destroyInputMirror();
358
+
359
+ inputMirrorTarget = element;
360
+
361
+ const overlay = document.createElement('div');
362
+ overlay.className = 'opengrammar-input-mirror';
363
+ overlay.style.cssText = `
364
+ position: fixed;
365
+ pointer-events: none;
366
+ z-index: 2147483645;
367
+ overflow: hidden;
368
+ background: transparent;
369
+ border-radius: inherit;
370
+ `;
371
+
372
+ const content = document.createElement('div');
373
+ content.className = 'opengrammar-input-mirror-content';
374
+ content.style.cssText = `
375
+ position: absolute;
376
+ top: 0;
377
+ left: 0;
378
+ width: max-content;
379
+ min-width: 100%;
380
+ color: inherit;
381
+ white-space: ${element.tagName === 'TEXTAREA' ? 'pre-wrap' : 'pre'};
382
+ word-break: break-word;
383
+ `;
384
+
385
+ copyTypographyStyles(element, overlay, content);
386
+ content.innerHTML = buildMirrorMarkup(getElementText(element), issues);
387
+ overlay.appendChild(content);
388
+ document.body.appendChild(overlay);
389
+
390
+ const input = element as HTMLInputElement | HTMLTextAreaElement;
391
+ const originalColor = input.dataset.ogOriginalColor || window.getComputedStyle(input).color;
392
+ input.dataset.ogOriginalColor = originalColor;
393
+ input.style.color = 'transparent';
394
+ input.style.webkitTextFillColor = 'transparent';
395
+ input.style.caretColor = originalColor;
396
+
397
+ const sync = () => syncInputMirrorScroll(element);
398
+ input.addEventListener('scroll', sync, { passive: true });
399
+
400
+ inputMirrorOverlay = overlay;
401
+ inputMirrorContent = content;
402
+ positionInputMirror(element);
403
+ syncInputMirrorScroll(element);
404
+
405
+ cleanupInputMirror = () => {
406
+ input.removeEventListener('scroll', sync);
407
+ input.style.color = input.dataset.ogOriginalColor || '';
408
+ input.style.webkitTextFillColor = '';
409
+ };
410
+}
411
+
412
+function destroyInputMirror() {
413
+ cleanupInputMirror?.();
414
+ cleanupInputMirror = null;
415
+ inputMirrorOverlay?.remove();
416
+ inputMirrorOverlay = null;
417
+ inputMirrorContent = null;
418
+ inputMirrorTarget = null;
419
+}
420
+
421
+function copyTypographyStyles(element: HTMLElement, overlay: HTMLElement, content: HTMLElement) {
422
+ const styles = window.getComputedStyle(element);
423
+ const target = element as HTMLInputElement | HTMLTextAreaElement;
424
+
425
+ overlay.style.left = `${element.getBoundingClientRect().left}px`;
426
+ overlay.style.top = `${element.getBoundingClientRect().top}px`;
427
+ overlay.style.width = `${element.getBoundingClientRect().width}px`;
428
+ overlay.style.height = `${element.getBoundingClientRect().height}px`;
429
+ overlay.style.borderRadius = styles.borderRadius;
430
+
431
+ content.style.font = styles.font;
432
+ content.style.fontFamily = styles.fontFamily;
433
+ content.style.fontSize = styles.fontSize;
434
+ content.style.fontWeight = styles.fontWeight;
435
+ content.style.lineHeight = styles.lineHeight;
436
+ content.style.letterSpacing = styles.letterSpacing;
437
+ content.style.textTransform = styles.textTransform;
438
+ content.style.textAlign = styles.textAlign;
439
+ content.style.padding = styles.padding;
440
+ content.style.color = target.dataset.ogOriginalColor || styles.color;
441
+}
442
+
443
+function positionInputMirror(element: HTMLElement) {
444
+ if (!inputMirrorOverlay) return;
445
+ const rect = element.getBoundingClientRect();
446
+ inputMirrorOverlay.style.left = `${rect.left}px`;
447
+ inputMirrorOverlay.style.top = `${rect.top}px`;
448
+ inputMirrorOverlay.style.width = `${rect.width}px`;
449
+ inputMirrorOverlay.style.height = `${rect.height}px`;
450
+}
451
+
452
+function syncInputMirrorScroll(element: HTMLElement) {
453
+ if (!inputMirrorContent) return;
454
+ const input = element as HTMLInputElement | HTMLTextAreaElement;
455
+ inputMirrorContent.style.transform = `translate(${-input.scrollLeft}px, ${-input.scrollTop}px)`;
456
+}
457
+
458
+function buildMirrorMarkup(text: string, issues: Issue[]): string {
459
+ if (!issues.length) {
460
+ return escapeHtmlPreservingWhitespace(text);
461
+ }
462
+
463
+ const sorted = [...issues].sort((a, b) => a.offset - b.offset);
464
+ let cursor = 0;
465
+ const parts: string[] = [];
466
+
467
+ for (const issue of sorted) {
468
+ const start = Math.max(0, Math.min(issue.offset, text.length));
469
+ const end = Math.max(start, Math.min(issue.offset + issue.length, text.length));
470
+
471
+ if (cursor < start) {
472
+ parts.push(escapeHtmlPreservingWhitespace(text.slice(cursor, start)));
473
+ }
474
+
475
+ const segment = text.slice(start, end);
476
+ const color = getColor(issue.type);
477
+ const bg = getInlineBackground(issue.type);
478
+ parts.push(
479
+ `<span style="color:${color};background:${bg};border-bottom:2px wavy ${color};border-radius:2px;">${escapeHtmlPreservingWhitespace(segment || issue.original)}</span>`,
480
+ );
481
+ cursor = end;
482
+ }
483
+
484
+ if (cursor < text.length) {
485
+ parts.push(escapeHtmlPreservingWhitespace(text.slice(cursor)));
486
+ }
487
+
488
+ return parts.join('');
489
+}
490
+
491
+function getElementText(element: HTMLElement): string {
492
+ if (element.tagName === 'TEXTAREA') {
493
+ return (element as HTMLTextAreaElement).value;
494
+ }
495
+ return (element as HTMLInputElement).value;
496
+}
497
+
498
+function escapeHtmlPreservingWhitespace(text: string): string {
499
+ return escapeHtml(text)
500
+ .replace(/\n/g, '<br>')
501
+ .replace(/ {2}/g, ' &nbsp;');
95
502
}
96
503
97
504
/**
98
505
* Show Grammarly-style tooltip popup
99
506
*/
100
507
function showTooltip(anchor: HTMLElement, issue: Issue, element: HTMLElement) {
508
+ uiActive = true;
509
+
101
510
// Remove existing tooltip
102
511
if (currentTooltip) {
103
512
currentTooltip.remove();
104
513
}
105
514
106
515
const anchorRect = anchor.getBoundingClientRect();
107
-
516
+
108
517
// Create tooltip
109
- const tooltip = document.createElement('div');
110
- tooltip.className = 'opengrammar-tooltip';
518
+ const tooltip = document.createElement("div");
519
+ tooltip.className = "opengrammar-tooltip";
520
+
521
+ // Calculate position - show below the highlight, or above if near bottom
522
+ let top = anchorRect.bottom + window.scrollY + 10;
523
+ if (top + 200 > window.innerHeight + window.scrollY) {
524
+ top = anchorRect.top + window.scrollY - 210;
525
+ }
526
+
111
527
tooltip.style.cssText = `
112
528
position: absolute;
113
- left: ${Math.min(anchorRect.left + window.scrollX, window.innerWidth - 320)}px;
114
- top: ${anchorRect.bottom + window.scrollY + 8}px;
529
+ left: ${Math.max(10, Math.min(anchorRect.left + window.scrollX - 150, window.innerWidth - 320))}px;
530
+ top: ${top}px;
115
531
width: 300px;
116
532
background: #ffffff;
117
533
border: 1px solid #e5e7eb;
118
534
border-radius: 8px;
119
- box-shadow: 0 8px 30px rgba(0, 0, 0, 0.15);
120
- z-index: 100001;
535
+ box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
536
+ z-index: 2147483647;
121
537
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
122
- font-size: 13px;
538
+ font-size: 14px;
123
539
overflow: hidden;
124
- animation: opengrammar-slide-up 0.2s ease-out;
125
540
`;
126
541
127
542
const typeColor = getColor(issue.type);
128
- const typeLabel = issue.type.charAt(0).toUpperCase() + issue.type.slice(1);
543
+ const typeLabel = getTypeLabel(issue.type);
544
+ const typeBg = getTypeBg(issue.type);
545
+ const confidence = typeof issue.confidence === "number" ? `${Math.round(issue.confidence * 100)}% confidence` : "Suggested";
546
+ const sourceLabel = issue.source ? issue.source.toUpperCase() : "RULE";
129
547
130
548
tooltip.innerHTML = `
131
- <div style="padding: 12px 14px; border-bottom: 1px solid #f3f4f6;">
132
- <div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
549
+ <div style="background: ${typeBg}; padding: 14px 16px; border-bottom: 1px solid #e5e7eb;">
550
+ <div style="display: flex; align-items: center; gap: 10px; margin-bottom: 10px;">
133
551
<span style="
134
- display: inline-block;
135
- width: 8px;
136
- height: 8px;
552
+ display: inline-flex;
553
+ align-items: center;
554
+ justify-content: center;
555
+ width: 20px;
556
+ height: 20px;
137
557
border-radius: 50%;
138
558
background: ${typeColor};
139
- "></span>
140
- <span style="font-weight: 600; color: #1f2937;">${typeLabel}</span>
559
+ color: white;
560
+ font-size: 12px;
561
+ font-weight: bold;
562
+ ">!</span>
563
+ <span style="font-weight: 600; color: #1f2937; font-size: 15px;">${typeLabel}</span>
141
564
</div>
142
- <div style="color: #6b7280; margin-bottom: 10px; line-height: 1.4;">${escapeHtml(issue.reason)}</div>
143
- <div style="
144
- background: #f9fafb;
145
- border-radius: 6px;
146
- padding: 10px 12px;
147
- ">
148
- <div style="margin-bottom: 6px;">
149
- <span style="color: #dc2626; text-decoration: line-through;">${escapeHtml(issue.original)}</span>
150
- </div>
151
- <div style="display: flex; align-items: center; gap: 8px;">
152
- <span style="color: #9ca3af;">→</span>
153
- <span style="color: #059669; font-weight: 600;">${escapeHtml(issue.suggestion)}</span>
154
- </div>
565
+ <div style="display:flex;gap:8px;margin-bottom:10px;flex-wrap:wrap;">
566
+ <span style="display:inline-flex;align-items:center;padding:4px 8px;border-radius:999px;background:white;color:#374151;font-size:11px;font-weight:600;">${confidence}</span>
567
+ <span style="display:inline-flex;align-items:center;padding:4px 8px;border-radius:999px;background:rgba(255,255,255,0.7);color:#4b5563;font-size:11px;font-weight:600;">${sourceLabel}</span>
568
+ </div>
569
+ <div style="color: #4b5563; line-height: 1.5; font-size: 14px;">${escapeHtml(issue.reason)}</div>
570
+ </div>
571
+ <div style="padding: 14px 16px; background: #f9fafb;">
572
+ <div style="margin-bottom: 12px;">
573
+ <div style="font-size: 12px; color: #6b7280; margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.5px;">Original</div>
574
+ <div style="color: #dc2626; text-decoration: line-through; background: #fef2f2; padding: 8px 10px; border-radius: 6px; font-family: inherit;">${escapeHtml(issue.original)}</div>
575
+ </div>
576
+ <div>
577
+ <div style="font-size: 12px; color: #6b7280; margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.5px;">Suggestion</div>
578
+ <div style="color: #059669; background: #f0fdf4; padding: 8px 10px; border-radius: 6px; font-weight: 600; font-family: inherit; border-left: 3px solid #059669;">${escapeHtml(issue.suggestion)}</div>
155
579
</div>
156
580
</div>
157
- <div style="display: flex; border-top: 1px solid #e5e7eb;">
581
+ <div style="display: flex; border-top: 1px solid #e5e7eb; background: white;">
158
582
<button class="og-apply-btn" style="
159
583
flex: 1;
160
- padding: 10px;
584
+ padding: 12px;
161
585
background: #2563eb;
162
586
color: white;
163
587
border: none;
164
588
cursor: pointer;
165
- font-size: 13px;
166
- font-weight: 500;
589
+ font-size: 14px;
590
+ font-weight: 600;
167
591
transition: background 0.15s;
592
+ border-right: 1px solid #e5e7eb;
168
593
">
169
- ✓ Apply
594
+ Apply
170
595
</button>
171
596
<button class="og-ignore-btn" style="
172
597
flex: 1;
173
- padding: 10px;
174
- background: #ffffff;
598
+ padding: 12px;
599
+ background: white;
175
600
color: #6b7280;
176
601
border: none;
177
- border-left: 1px solid #e5e7eb;
178
602
cursor: pointer;
179
- font-size: 13px;
603
+ font-size: 14px;
604
+ font-weight: 500;
180
605
transition: background 0.15s;
181
606
">
182
- ✕ Ignore
607
+ Ignore
183
608
</button>
184
609
</div>
185
610
`;
186
611
187
612
// Add event listeners
188
- const applyBtn = tooltip.querySelector('.og-apply-btn') as HTMLButtonElement;
189
- const ignoreBtn = tooltip.querySelector('.og-ignore-btn') as HTMLButtonElement;
613
+ const applyBtn = tooltip.querySelector(".og-apply-btn") as HTMLButtonElement;
614
+ const ignoreBtn = tooltip.querySelector(
615
+ ".og-ignore-btn",
616
+ ) as HTMLButtonElement;
190
617
191
- applyBtn.addEventListener('click', (e) => {
618
+ applyBtn.addEventListener("click", (e) => {
192
619
e.stopPropagation();
193
- applySuggestion(element, issue);
620
+ e.preventDefault();
621
+ applySuggestion(element, issue, anchor);
194
622
hideTooltip();
195
623
});
196
624
197
- ignoreBtn.addEventListener('click', (e) => {
625
+ ignoreBtn.addEventListener("click", (e) => {
198
626
e.stopPropagation();
199
- ignoreIssue(issue);
627
+ e.preventDefault();
628
+ ignoreIssue(issue, anchor);
200
629
hideTooltip();
201
- anchor.style.opacity = '0.3';
202
630
});
203
631
204
632
// Button hover effects
205
- applyBtn.addEventListener('mouseenter', () => {
206
- applyBtn.style.background = '#1d4ed8';
633
+ applyBtn.addEventListener("mouseenter", () => {
634
+ applyBtn.style.background = "#1d4ed8";
207
635
});
208
- applyBtn.addEventListener('mouseleave', () => {
209
- applyBtn.style.background = '#2563eb';
636
+ applyBtn.addEventListener("mouseleave", () => {
637
+ applyBtn.style.background = "#2563eb";
210
638
});
211
639
212
- ignoreBtn.addEventListener('mouseenter', () => {
213
- ignoreBtn.style.background = '#f9fafb';
640
+ ignoreBtn.addEventListener("mouseenter", () => {
641
+ ignoreBtn.style.background = "#f9fafb";
214
642
});
215
- ignoreBtn.addEventListener('mouseleave', () => {
216
- ignoreBtn.style.background = '#ffffff';
643
+ ignoreBtn.addEventListener("mouseleave", () => {
644
+ ignoreBtn.style.background = "#ffffff";
217
645
});
218
646
219
647
// Close tooltip when clicking outside
220
648
const closeOnOutsideClick = (e: MouseEvent) => {
221
- if (!tooltip.contains(e.target as Node) && !anchor.contains(e.target as Node)) {
649
+ if (
650
+ !tooltip.contains(e.target as Node) &&
651
+ !anchor.contains(e.target as Node)
652
+ ) {
222
653
hideTooltip();
223
- document.removeEventListener('click', closeOnOutsideClick);
654
+ document.removeEventListener("click", closeOnOutsideClick);
224
655
}
225
656
};
226
657
227
658
setTimeout(() => {
228
- document.addEventListener('click', closeOnOutsideClick);
659
+ document.addEventListener("click", closeOnOutsideClick);
229
660
}, 100);
230
661
662
+ // Prevent focus loss from target element when interacting with tooltip
663
+ tooltip.addEventListener("mousedown", (e) => {
664
+ e.preventDefault();
665
+ });
666
+
231
667
document.body.appendChild(tooltip);
232
668
currentTooltip = tooltip;
233
669
}
234
670
671
+function getTypeLabel(type: string): string {
672
+ switch (type) {
673
+ case "grammar":
674
+ return "Grammar";
675
+ case "spelling":
676
+ return "Spelling";
677
+ case "clarity":
678
+ return "Clarity";
679
+ case "style":
680
+ return "Style";
681
+ default:
682
+ return "Suggestion";
683
+ }
684
+}
685
+
686
+function getTypeBg(type: string): string {
687
+ switch (type) {
688
+ case "grammar":
689
+ case "spelling":
690
+ return "#fef2f2";
691
+ case "clarity":
692
+ return "#fffbeb";
693
+ case "style":
694
+ return "#eff6ff";
695
+ default:
696
+ return "#fef2f2";
697
+ }
698
+}
699
+
235
700
/**
236
701
* Hide current tooltip
237
702
*/
240
705
currentTooltip.remove();
241
706
currentTooltip = null;
242
707
}
708
+ uiActive = false;
243
709
}
244
710
245
711
/**
246
712
* Apply suggestion to text
247
713
*/
248
-function applySuggestion(element: HTMLElement, issue: Issue) {
249
- if (element.tagName === 'INPUT' || element.tagName === 'TEXTAREA') {
714
+function applySuggestion(
715
+ element: HTMLElement,
716
+ issue: Issue,
717
+ highlightEl: HTMLElement,
718
+) {
719
+ void chrome.runtime.sendMessage({
720
+ type: 'TRACK_ANALYTICS_EVENT',
721
+ eventType: 'suggestions_applied',
722
+ payload: { count: 1, domain: window.location.hostname },
723
+ });
724
+
725
+ if (element.tagName === "INPUT" || element.tagName === "TEXTAREA") {
250
726
const input = element as HTMLInputElement | HTMLTextAreaElement;
251
727
const text = input.value;
252
728
const before = text.substring(0, issue.offset);
253
729
const after = text.substring(issue.offset + issue.length);
254
730
input.value = before + issue.suggestion + after;
255
- input.dispatchEvent(new Event('input', { bubbles: true }));
731
+
732
+ // Focus back on the input element
733
+ input.focus();
734
+
735
+ // Show success on badge: green checkmark, then fade away
736
+ document.querySelectorAll(".opengrammar-badge").forEach((b) => {
737
+ const badge = b as HTMLElement;
738
+ badge.textContent = "\u2713";
739
+ badge.style.background = "linear-gradient(135deg, #10b981, #059669)";
740
+ badge.style.boxShadow = "0 2px 8px rgba(16, 185, 129, 0.5)";
741
+ setTimeout(() => {
742
+ badge.style.opacity = "0";
743
+ badge.style.transform = "scale(0.8)";
744
+ badge.style.transition = "opacity 0.3s, transform 0.3s";
745
+ setTimeout(() => badge.remove(), 300);
746
+ }, 600);
747
+ });
748
+
749
+ // Trigger re-analysis
750
+ input.dispatchEvent(new Event("input", { bubbles: true }));
256
751
} else if (element.isContentEditable) {
257
- // For contenteditable, we need to find and replace the text
258
- const range = findTextRange(element, issue.original, issue.offset);
259
- if (range) {
260
- range.deleteContents();
261
- range.insertNode(document.createTextNode(issue.suggestion));
262
- element.dispatchEvent(new Event('input', { bubbles: true }));
752
+ // Replace the highlighted span with corrected text + green success flash
753
+ const parent = highlightEl.parentNode;
754
+ if (parent) {
755
+ const successSpan = document.createElement("span");
756
+ successSpan.className = "opengrammar-success";
757
+ successSpan.textContent = issue.suggestion;
758
+ successSpan.style.cssText = `
759
+ background: rgba(16, 185, 129, 0.15);
760
+ border-bottom: 2px solid #10b981;
761
+ border-radius: 2px;
762
+ padding-bottom: 1px;
763
+ transition: background 0.5s, border-color 0.5s;
764
+ `;
765
+ parent.replaceChild(successSpan, highlightEl);
766
+
767
+ // Fade out the green highlight then unwrap to plain text
768
+ requestAnimationFrame(() => {
769
+ setTimeout(() => {
770
+ successSpan.style.background = "transparent";
771
+ successSpan.style.borderColor = "transparent";
772
+ setTimeout(() => {
773
+ const textNode = document.createTextNode(issue.suggestion);
774
+ if (successSpan.parentNode) {
775
+ successSpan.parentNode.replaceChild(textNode, successSpan);
776
+ textNode.parentNode?.normalize();
777
+ }
778
+ }, 500);
779
+ }, 50);
780
+ });
263
781
}
264
- }
265
782
266
- // Trigger re-analysis
267
- setTimeout(() => {
268
- element.dispatchEvent(new Event('input', { bubbles: true }));
269
- }, 100);
783
+ // Trigger re-analysis
784
+ element.dispatchEvent(new Event("input", { bubbles: true }));
785
+ }
270
786
}
271
787
272
788
/**
273
789
* Ignore an issue
274
790
*/
275
-function ignoreIssue(issue: Issue) {
276
- chrome.storage.sync.get(['ignoredIssues'], (result) => {
277
- const ignoredIssues = result.ignoredIssues || [];
791
+function ignoreIssue(issue: Issue, highlightEl: HTMLElement) {
792
+ void chrome.runtime.sendMessage({
793
+ type: 'TRACK_ANALYTICS_EVENT',
794
+ eventType: 'suggestions_ignored',
795
+ payload: { count: 1, domain: window.location.hostname },
796
+ });
797
+
798
+ // Save to storage
799
+ chrome.storage.sync.get(["ignoredIssues"], (result) => {
800
+ const ignoredIssues = normalizeIgnoredIssues(result.ignoredIssues);
278
801
const issueId = `${issue.type}-${issue.offset}-${issue.original}`;
279
- if (!ignoredIssues.includes(issueId)) {
280
- ignoredIssues.push(issueId);
802
+ if (!ignoredIssues.some((entry) => entry.id === issueId)) {
803
+ ignoredIssues.push({
804
+ id: issueId,
805
+ type: issue.type,
806
+ original: issue.original,
807
+ suggestion: issue.suggestion,
808
+ ignoredAt: Date.now(),
809
+ });
281
810
chrome.storage.sync.set({ ignoredIssues });
282
811
}
283
812
});
813
+
814
+ // For inline highlights (contenteditable)
815
+ if (highlightEl.classList.contains("opengrammar-highlight")) {
816
+ highlightEl.style.borderBottom = "none";
817
+ highlightEl.style.background = "transparent";
818
+ highlightEl.classList.remove("opengrammar-highlight");
819
+ highlightEl.classList.add("opengrammar-ignored");
820
+ }
821
+
822
+ // For badge (textarea/input) - show success then fade
823
+ if (highlightEl.classList.contains("opengrammar-badge")) {
824
+ highlightEl.textContent = "\u2713";
825
+ highlightEl.style.background = "linear-gradient(135deg, #10b981, #059669)";
826
+ highlightEl.style.boxShadow = "0 2px 8px rgba(16, 185, 129, 0.5)";
827
+ setTimeout(() => {
828
+ highlightEl.style.opacity = "0";
829
+ highlightEl.style.transform = "scale(0.8)";
830
+ highlightEl.style.transition = "opacity 0.3s, transform 0.3s";
831
+ setTimeout(() => highlightEl.remove(), 300);
832
+ }, 600);
833
+ }
834
+}
835
+
836
+function normalizeIgnoredIssues(value: unknown): IgnoredIssue[] {
837
+ if (!Array.isArray(value)) {
838
+ return [];
839
+ }
840
+
841
+ return value
842
+ .map((entry) => {
843
+ if (typeof entry === 'string') {
844
+ return {
845
+ id: entry,
846
+ type: 'grammar' as const,
847
+ original: entry,
848
+ suggestion: '',
849
+ ignoredAt: Date.now(),
850
+ };
851
+ }
852
+
853
+ if (
854
+ entry &&
855
+ typeof entry === 'object' &&
856
+ 'id' in entry &&
857
+ 'type' in entry &&
858
+ 'original' in entry &&
859
+ 'suggestion' in entry
860
+ ) {
861
+ return entry as IgnoredIssue;
862
+ }
863
+
864
+ return null;
865
+ })
866
+ .filter((entry): entry is IgnoredIssue => Boolean(entry));
284
867
}
285
868
286
869
/**
287
870
* Show badge for input/textarea elements
288
871
*/
289
872
function showInputBadge(element: HTMLElement, issues: Issue[]) {
873
+ // Remove existing badge
874
+ document.querySelectorAll(".opengrammar-badge").forEach((el) => el.remove());
875
+
290
876
const rect = element.getBoundingClientRect();
291
-
292
- const badge = document.createElement('div');
293
- badge.className = 'opengrammar-badge';
877
+
878
+ const badge = document.createElement("div");
879
+ badge.className = "opengrammar-badge";
294
880
badge.style.cssText = `
295
- position: absolute;
296
- left: ${rect.right + window.scrollX - 30}px;
297
- top: ${rect.top + window.scrollY + 10}px;
298
- width: 24px;
881
+ position: fixed;
882
+ left: ${rect.right - 35}px;
883
+ top: ${rect.top + 5}px;
884
+ min-width: 24px;
299
885
height: 24px;
300
886
background: linear-gradient(135deg, #ef4444, #dc2626);
301
887
color: white;
302
- border-radius: 50%;
888
+ border-radius: 12px;
303
889
display: flex;
304
890
align-items: center;
305
891
justify-content: center;
306
892
font-size: 12px;
307
- font-weight: bold;
308
- z-index: 10000;
893
+ font-weight: 700;
894
+ z-index: 2147483646;
309
895
cursor: pointer;
310
- box-shadow: 0 2px 8px rgba(239, 68, 68, 0.4);
311
- transition: transform 0.2s;
896
+ box-shadow: 0 2px 8px rgba(239, 68, 68, 0.5);
897
+ transition: transform 0.15s, box-shadow 0.15s, background 0.3s, opacity 0.3s;
898
+ pointer-events: auto;
899
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
900
+ padding: 0 6px;
312
901
`;
313
-
902
+
314
903
badge.textContent = issues.length.toString();
315
-
316
- badge.addEventListener('mouseenter', () => {
317
- badge.style.transform = 'scale(1.1)';
904
+ let currentIssueIndex = 0;
905
+
906
+ badge.addEventListener("mouseenter", () => {
907
+ badge.style.transform = "scale(1.1)";
908
+ badge.style.boxShadow = "0 4px 12px rgba(239, 68, 68, 0.6)";
909
+ });
910
+ badge.addEventListener("mouseleave", () => {
911
+ badge.style.transform = "scale(1)";
912
+ badge.style.boxShadow = "0 2px 8px rgba(239, 68, 68, 0.5)";
318
913
});
319
- badge.addEventListener('mouseleave', () => {
320
- badge.style.transform = 'scale(1)';
914
+
915
+ // Prevent textarea/input from losing focus when badge is clicked
916
+ badge.addEventListener("mousedown", (e) => {
917
+ e.preventDefault();
321
918
});
322
-
323
- badge.addEventListener('click', (e) => {
919
+
920
+ badge.addEventListener("click", (e) => {
324
921
e.stopPropagation();
325
- // Show first issue as tooltip
922
+ e.preventDefault();
326
923
if (issues.length > 0) {
327
- showTooltip(badge, issues[0], element);
924
+ showIssuePanel(badge, issues, element, currentIssueIndex, (newIndex) => {
925
+ currentIssueIndex = newIndex;
926
+ });
328
927
}
329
928
});
330
-
929
+
331
930
document.body.appendChild(badge);
332
931
}
333
932
334
933
/**
335
- * Get color based on issue type
934
+ * Grammarly-style issue panel for input/textarea elements.
935
+ * Shows current issue with Apply/Dismiss buttons and prev/next navigation.
336
936
*/
337
-function getColor(type: string): string {
338
- switch (type) {
339
- case 'grammar': return '#ef4444'; // Red
340
- case 'spelling': return '#ef4444'; // Red
341
- case 'clarity': return '#f59e0b'; // Amber
342
- case 'style': return '#3b82f6'; // Blue
343
- default: return '#ef4444';
344
- }
345
-}
937
+function showIssuePanel(
938
+ anchor: HTMLElement,
939
+ issues: Issue[],
940
+ element: HTMLElement,
941
+ currentIndex: number,
942
+ onIndexChange: (newIndex: number) => void,
943
+) {
944
+ uiActive = true;
346
945
347
-/**
348
- * Find text range in DOM
349
- */
350
-function findTextRange(root: HTMLElement, textToFind: string, startOffset: number): Range | null {
351
- if (root.tagName === 'TEXTAREA' || root.tagName === 'INPUT') {
352
- return null;
946
+ if (currentTooltip) {
947
+ currentTooltip.remove();
353
948
}
354
949
355
- const range = document.createRange();
356
- const treeWalker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
950
+ const issue = issues[currentIndex];
951
+ const anchorRect = anchor.getBoundingClientRect();
357
952
358
- let currentNode: Node | null = treeWalker.nextNode();
359
- let currentOffset = 0;
953
+ const panel = document.createElement("div");
954
+ panel.className = "opengrammar-tooltip";
360
955
361
- while (currentNode) {
362
- const nodeLength = currentNode.textContent?.length || 0;
956
+ let top = anchorRect.bottom + 10;
957
+ if (top + 280 > window.innerHeight) {
958
+ top = anchorRect.top - 290;
959
+ }
363
960
364
- if (currentOffset + nodeLength > startOffset) {
365
- const startInNode = startOffset - currentOffset;
961
+ panel.style.cssText = `
962
+ position: fixed;
963
+ left: ${Math.max(10, Math.min(anchorRect.left - 130, window.innerWidth - 320))}px;
964
+ top: ${top}px;
965
+ width: 300px;
966
+ background: #ffffff;
967
+ border: 1px solid #e5e7eb;
968
+ border-radius: 12px;
969
+ box-shadow: 0 10px 40px rgba(0, 0, 0, 0.15), 0 0 0 1px rgba(0, 0, 0, 0.05);
970
+ z-index: 2147483647;
971
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
972
+ font-size: 14px;
973
+ overflow: hidden;
974
+ animation: og-panel-in 0.15s ease-out;
975
+ `;
976
+
977
+ const typeColor = getColor(issue.type);
978
+ const typeLabel = getTypeLabel(issue.type);
979
+ const typeBg = getTypeBg(issue.type);
980
+ const hasMultiple = issues.length > 1;
366
981
367
- if (startInNode + textToFind.length <= nodeLength) {
368
- range.setStart(currentNode, startInNode);
369
- range.setEnd(currentNode, startInNode + textToFind.length);
370
- return range;
371
- } else {
372
- range.setStart(currentNode, startInNode);
373
- range.setEnd(currentNode, nodeLength);
374
- return range;
982
+ panel.innerHTML = `
983
+ <style>
984
+ @keyframes og-panel-in {
985
+ from { opacity: 0; transform: translateY(-4px); }
986
+ to { opacity: 1; transform: translateY(0); }
375
987
}
988
+ </style>
989
+ ${
990
+ hasMultiple
991
+ ? `
992
+ <div style="display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; background: #f3f4f6; border-bottom: 1px solid #e5e7eb;">
993
+ <button class="og-prev-btn" style="
994
+ padding: 4px 10px; background: white; border: 1px solid #d1d5db; border-radius: 6px;
995
+ cursor: ${currentIndex === 0 ? "default" : "pointer"}; font-size: 12px; color: #374151;
996
+ opacity: ${currentIndex === 0 ? "0.4" : "1"};
997
+ ">&#8592;</button>
998
+ <span style="font-size: 12px; color: #6b7280; font-weight: 600;">${currentIndex + 1} of ${issues.length} issues</span>
999
+ <button class="og-next-btn" style="
1000
+ padding: 4px 10px; background: white; border: 1px solid #d1d5db; border-radius: 6px;
1001
+ cursor: ${currentIndex >= issues.length - 1 ? "default" : "pointer"}; font-size: 12px; color: #374151;
1002
+ opacity: ${currentIndex >= issues.length - 1 ? "0.4" : "1"};
1003
+ ">&#8594;</button>
1004
+ </div>
1005
+ `
1006
+ : ""
376
1007
}
1008
+ <div style="background: ${typeBg}; padding: 14px 16px; border-bottom: 1px solid #e5e7eb;">
1009
+ <div style="display: flex; align-items: center; gap: 10px; margin-bottom: 10px;">
1010
+ <span style="
1011
+ display: inline-flex; align-items: center; justify-content: center;
1012
+ width: 20px; height: 20px; border-radius: 50%;
1013
+ background: ${typeColor}; color: white; font-size: 12px; font-weight: bold;
1014
+ ">!</span>
1015
+ <span style="font-weight: 600; color: #1f2937; font-size: 15px;">${typeLabel}</span>
1016
+ </div>
1017
+ <div style="color: #4b5563; line-height: 1.5; font-size: 14px;">${escapeHtml(issue.reason)}</div>
1018
+ </div>
1019
+ <div style="padding: 14px 16px; background: #f9fafb;">
1020
+ <div style="margin-bottom: 12px;">
1021
+ <div style="font-size: 11px; color: #9ca3af; margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.5px; font-weight: 600;">Original</div>
1022
+ <div style="color: #dc2626; text-decoration: line-through; background: #fef2f2; padding: 8px 10px; border-radius: 6px; font-family: inherit;">${escapeHtml(issue.original)}</div>
1023
+ </div>
1024
+ <div>
1025
+ <div style="font-size: 11px; color: #9ca3af; margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.5px; font-weight: 600;">Suggestion</div>
1026
+ <div class="og-suggestion-click" style="
1027
+ color: #059669; background: #f0fdf4; padding: 8px 10px; border-radius: 6px;
1028
+ font-weight: 600; font-family: inherit; border-left: 3px solid #059669;
1029
+ cursor: pointer; transition: background 0.15s;
1030
+ ">${escapeHtml(issue.suggestion)}</div>
1031
+ </div>
1032
+ </div>
1033
+ <div style="display: flex; border-top: 1px solid #e5e7eb; background: white;">
1034
+ <button class="og-apply-btn" style="
1035
+ flex: 1; padding: 12px; background: #2563eb; color: white;
1036
+ border: none; cursor: pointer; font-size: 14px; font-weight: 600;
1037
+ transition: background 0.15s; border-right: 1px solid #e5e7eb;
1038
+ border-radius: 0 0 0 12px;
1039
+ ">&#10003; Apply Fix</button>
1040
+ <button class="og-ignore-btn" style="
1041
+ flex: 1; padding: 12px; background: white; color: #6b7280;
1042
+ border: none; cursor: pointer; font-size: 14px; font-weight: 500;
1043
+ transition: background 0.15s; border-radius: 0 0 12px 0;
1044
+ ">Dismiss</button>
1045
+ </div>
1046
+ `;
1047
+
1048
+ // Prevent focus loss from the textarea when interacting with the panel
1049
+ panel.addEventListener("mousedown", (e) => {
1050
+ e.preventDefault();
1051
+ });
1052
+
1053
+ const applyBtn = panel.querySelector(".og-apply-btn") as HTMLButtonElement;
1054
+ const ignoreBtn = panel.querySelector(".og-ignore-btn") as HTMLButtonElement;
1055
+ const suggestionEl = panel.querySelector(
1056
+ ".og-suggestion-click",
1057
+ ) as HTMLElement;
1058
+
1059
+ // Clicking the green suggestion text also applies the fix (Grammarly-style)
1060
+ if (suggestionEl) {
1061
+ suggestionEl.addEventListener("click", (e) => {
1062
+ e.stopPropagation();
1063
+ applySuggestion(element, issue, anchor);
1064
+ hideTooltip();
1065
+ });
1066
+ suggestionEl.addEventListener("mouseenter", () => {
1067
+ suggestionEl.style.background = "#dcfce7";
1068
+ });
1069
+ suggestionEl.addEventListener("mouseleave", () => {
1070
+ suggestionEl.style.background = "#f0fdf4";
1071
+ });
1072
+ }
1073
+
1074
+ applyBtn.addEventListener("click", (e) => {
1075
+ e.stopPropagation();
1076
+ e.preventDefault();
1077
+ applySuggestion(element, issue, anchor);
1078
+ hideTooltip();
1079
+ });
377
1080
378
- currentOffset += nodeLength;
379
- currentNode = treeWalker.nextNode();
1081
+ ignoreBtn.addEventListener("click", (e) => {
1082
+ e.stopPropagation();
1083
+ e.preventDefault();
1084
+ ignoreIssue(issue, anchor);
1085
+ hideTooltip();
1086
+ });
1087
+
1088
+ // Hover effects
1089
+ applyBtn.addEventListener("mouseenter", () => {
1090
+ applyBtn.style.background = "#1d4ed8";
1091
+ });
1092
+ applyBtn.addEventListener("mouseleave", () => {
1093
+ applyBtn.style.background = "#2563eb";
1094
+ });
1095
+ ignoreBtn.addEventListener("mouseenter", () => {
1096
+ ignoreBtn.style.background = "#f9fafb";
1097
+ });
1098
+ ignoreBtn.addEventListener("mouseleave", () => {
1099
+ ignoreBtn.style.background = "#ffffff";
1100
+ });
1101
+
1102
+ // Navigation for multiple issues
1103
+ if (hasMultiple) {
1104
+ const prevBtn = panel.querySelector(".og-prev-btn") as HTMLButtonElement;
1105
+ const nextBtn = panel.querySelector(".og-next-btn") as HTMLButtonElement;
1106
+
1107
+ if (prevBtn && currentIndex > 0) {
1108
+ prevBtn.addEventListener("click", (e) => {
1109
+ e.stopPropagation();
1110
+ const newIndex = currentIndex - 1;
1111
+ onIndexChange(newIndex);
1112
+ showIssuePanel(anchor, issues, element, newIndex, onIndexChange);
1113
+ });
1114
+ }
1115
+ if (nextBtn && currentIndex < issues.length - 1) {
1116
+ nextBtn.addEventListener("click", (e) => {
1117
+ e.stopPropagation();
1118
+ const newIndex = currentIndex + 1;
1119
+ onIndexChange(newIndex);
1120
+ showIssuePanel(anchor, issues, element, newIndex, onIndexChange);
1121
+ });
1122
+ }
380
1123
}
381
1124
382
- return null;
1125
+ // Close on outside click
1126
+ const closeOnOutsideClick = (e: MouseEvent) => {
1127
+ if (
1128
+ !panel.contains(e.target as Node) &&
1129
+ !anchor.contains(e.target as Node)
1130
+ ) {
1131
+ hideTooltip();
1132
+ document.removeEventListener("click", closeOnOutsideClick);
1133
+ }
1134
+ };
1135
+
1136
+ setTimeout(() => {
1137
+ document.addEventListener("click", closeOnOutsideClick);
1138
+ }, 100);
1139
+
1140
+ document.body.appendChild(panel);
1141
+ currentTooltip = panel;
1142
+}
1143
+
1144
+/**
1145
+ * Get color based on issue type
1146
+ */
1147
+function getColor(type: string): string {
1148
+ switch (type) {
1149
+ case "grammar":
1150
+ return "#ef4444"; // Red
1151
+ case "spelling":
1152
+ return "#ef4444"; // Red
1153
+ case "clarity":
1154
+ return "#f59e0b"; // Amber
1155
+ case "style":
1156
+ return "#3b82f6"; // Blue
1157
+ default:
1158
+ return "#ef4444";
1159
+ }
383
1160
}
384
1161
385
1162
/**
386
1163
* Escape HTML to prevent XSS
387
1164
*/
388
1165
function escapeHtml(text: string): string {
389
- const div = document.createElement('div');
1166
+ const div = document.createElement("div");
390
1167
div.textContent = text;
391
1168
return div.innerHTML;
392
1169
}
393
1170
394
-// Add CSS animation
395
-if (!document.getElementById('opengrammar-styles')) {
396
- const style = document.createElement('style');
397
- style.id = 'opengrammar-styles';
398
- style.textContent = `
399
- @keyframes opengrammar-slide-up {
400
- from {
401
- opacity: 0;
402
- transform: translateY(-10px);
403
- }
404
- to {
405
- opacity: 1;
406
- transform: translateY(0);
407
- }
408
- }
409
-
410
- @keyframes opengrammar-fade-in {
411
- from { opacity: 0; }
412
- to { opacity: 1; }
413
- }
414
- `;
415
- document.head.appendChild(style);
1171
+// Initialize highlight container on load
1172
+if (document.readyState === "loading") {
1173
+ document.addEventListener("DOMContentLoaded", initHighlightContainer);
1174
+} else {
1175
+ initHighlightContainer();
416
1176
}
417
1177