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
Showing 1 changed file+981-221
▼opengrammar/extension/src/content/highlighter.ts
+981-221
| @@ -1,237 +1,702 @@ | ||
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, ' '); | |
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,177 +705,472 @@ function hideTooltip() { | ||
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 | + ">←</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 | + ">→</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 | + ">✓ 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 | |