Skip to content

Commit dcdd812

Browse files
author
Jarvis
committed
fix: prevent orange underlines on input fields + scroll bugs
- Enhanced isWithinEditable() to detect modern web app patterns: - role='textbox', 'searchbox', 'combobox' (Gmail, Twitter, etc.) - common editable class patterns (editable, compose, editor, search) - data-editable and data-editor attributes - walks full DOM tree instead of just checking immediate parent - Fixed scroll flickering: - renderUnderlines() now reuses existing DOM elements - only updates position styles instead of recreating elements - prevents animation from re-triggering on every scroll - Fixed stale underlines persisting after content changes: - added SPA navigation detection (URL change clears all underlines) - cleanup now checks if element still contains cipher text - added mutation observer for content removal - Fixed duplicate underlines on same quack: - cipher ID now uses content hash only (not position) - scroll position changes no longer create duplicate entries - Performance improvements: - 50ms throttle on scroll/resize handlers - hidden underlines when element scrolls out (vs removing)
1 parent 609a2a8 commit dcdd812

File tree

2 files changed

+220
-59
lines changed

2 files changed

+220
-59
lines changed

src/content/secure-display.ts

Lines changed: 166 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -322,63 +322,94 @@ function setUnderlineHover(cipherId: string, hovered: boolean): void {
322322

323323
/**
324324
* Render underlines for a detected cipher
325+
*
326+
* Reuses existing DOM elements when possible (just updates positions)
327+
* to avoid re-triggering animations on scroll.
325328
*/
326329
function renderUnderlines(cipher: DetectedCipher): void {
327-
// Clear old underlines
328-
cipher.underlines.forEach(u => u.remove());
329-
cipher.hitboxes.forEach(h => h.remove());
330-
cipher.underlines = [];
331-
cipher.hitboxes = [];
332-
333-
cipher.rects.forEach((rect) => {
334-
const underline = document.createElement('div');
335-
underline.className = 'quack-underline';
336-
underline.style.left = `${rect.left}px`;
337-
underline.style.top = `${rect.bottom - 3}px`;
338-
underline.style.width = `${rect.width}px`;
339-
underline.style.height = '3px';
340-
underline.style.pointerEvents = 'none';
341-
342-
const hitbox = document.createElement('div');
343-
hitbox.className = 'quack-underline-hit';
344-
hitbox.style.left = `${rect.left}px`;
345-
hitbox.style.top = `${rect.bottom - 8}px`;
346-
hitbox.style.width = `${rect.width}px`;
347-
hitbox.style.height = '10px';
348-
hitbox.tabIndex = -1;
349-
350-
hitbox.addEventListener('mouseenter', () => {
351-
if (hoverTimer) {
352-
clearTimeout(hoverTimer);
353-
hoverTimer = null;
354-
}
355-
// Get anchor rect (lowest point)
356-
const lowestRect = cipher.rects.reduce((acc, r) =>
357-
r.bottom > acc.bottom ? r : acc, cipher.rects[0]);
358-
showHoverCard(cipher, lowestRect);
359-
});
360-
361-
hitbox.addEventListener('mouseleave', () => {
362-
scheduleHoverHide();
363-
});
364-
365-
document.body.appendChild(underline);
366-
document.body.appendChild(hitbox);
367-
cipher.underlines.push(underline);
368-
cipher.hitboxes.push(hitbox);
330+
const rectsCount = cipher.rects.length;
331+
const existingCount = cipher.underlines.length;
332+
333+
// Update existing elements or create new ones
334+
cipher.rects.forEach((rect, idx) => {
335+
if (idx < existingCount) {
336+
// Reuse existing element - just update position
337+
const underline = cipher.underlines[idx];
338+
const hitbox = cipher.hitboxes[idx];
339+
340+
underline.style.left = `${rect.left}px`;
341+
underline.style.top = `${rect.bottom - 3}px`;
342+
underline.style.width = `${rect.width}px`;
343+
344+
hitbox.style.left = `${rect.left}px`;
345+
hitbox.style.top = `${rect.bottom - 8}px`;
346+
hitbox.style.width = `${rect.width}px`;
347+
} else {
348+
// Create new element
349+
const underline = document.createElement('div');
350+
underline.className = 'quack-underline';
351+
underline.style.left = `${rect.left}px`;
352+
underline.style.top = `${rect.bottom - 3}px`;
353+
underline.style.width = `${rect.width}px`;
354+
underline.style.height = '3px';
355+
underline.style.pointerEvents = 'none';
356+
357+
const hitbox = document.createElement('div');
358+
hitbox.className = 'quack-underline-hit';
359+
hitbox.style.left = `${rect.left}px`;
360+
hitbox.style.top = `${rect.bottom - 8}px`;
361+
hitbox.style.width = `${rect.width}px`;
362+
hitbox.style.height = '10px';
363+
hitbox.tabIndex = -1;
364+
365+
hitbox.addEventListener('mouseenter', () => {
366+
if (hoverTimer) {
367+
clearTimeout(hoverTimer);
368+
hoverTimer = null;
369+
}
370+
// Get anchor rect (lowest point)
371+
const lowestRect = cipher.rects.reduce((acc, r) =>
372+
r.bottom > acc.bottom ? r : acc, cipher.rects[0]);
373+
showHoverCard(cipher, lowestRect);
374+
});
375+
376+
hitbox.addEventListener('mouseleave', () => {
377+
scheduleHoverHide();
378+
});
379+
380+
document.body.appendChild(underline);
381+
document.body.appendChild(hitbox);
382+
cipher.underlines.push(underline);
383+
cipher.hitboxes.push(hitbox);
384+
}
369385
});
386+
387+
// Remove excess elements (if rects shrunk)
388+
while (cipher.underlines.length > rectsCount) {
389+
cipher.underlines.pop()?.remove();
390+
cipher.hitboxes.pop()?.remove();
391+
}
370392
}
371393

372394
// ============================================================================
373395
// Detection & Decryption
374396
// ============================================================================
375397

376398
/**
377-
* Generate unique ID for a cipher based on content and position
399+
* Generate unique ID for a cipher based on content only (stable across scroll)
400+
*
401+
* Uses a hash of the full encrypted string to avoid duplicates when the same
402+
* cipher appears in different elements, but still be stable across scrolls.
378403
*/
379-
function generateCipherId(encrypted: string, element: HTMLElement): string {
380-
const rect = element.getBoundingClientRect();
381-
return `${encrypted.substring(0, 20)}-${Math.round(rect.top)}-${Math.round(rect.left)}`;
404+
function generateCipherId(encrypted: string, _element: HTMLElement): string {
405+
// Simple hash of the encrypted content for stability
406+
let hash = 0;
407+
for (let i = 0; i < encrypted.length; i++) {
408+
const char = encrypted.charCodeAt(i);
409+
hash = ((hash << 5) - hash) + char;
410+
hash = hash & hash; // Convert to 32-bit integer
411+
}
412+
return `quack-${Math.abs(hash).toString(36)}-${encrypted.length}`;
382413
}
383414

384415
/**
@@ -563,30 +594,109 @@ export function setupSecureScanning(): void {
563594
subtree: true,
564595
});
565596

566-
// Handle scroll/resize - update underline positions
597+
// Handle scroll/resize - update underline positions with throttling
567598
let rafPending = false;
599+
let lastUpdateTime = 0;
600+
const UPDATE_THROTTLE_MS = 50; // Max update frequency
601+
568602
const updatePositions = () => {
569-
if (rafPending) return;
603+
const now = Date.now();
604+
if (rafPending || now - lastUpdateTime < UPDATE_THROTTLE_MS) return;
605+
570606
rafPending = true;
571607
requestAnimationFrame(() => {
572608
rafPending = false;
573-
detectedCiphers.forEach(cipher => {
574-
if (document.body.contains(cipher.element)) {
575-
cipher.rects = getCipherRects(cipher.element, cipher.encrypted);
576-
renderUnderlines(cipher);
577-
} else {
578-
// Element removed from DOM
609+
lastUpdateTime = Date.now();
610+
611+
const ciphersToRemove: string[] = [];
612+
613+
detectedCiphers.forEach((cipher, id) => {
614+
// Check if element is still in DOM and visible
615+
if (!document.body.contains(cipher.element)) {
616+
ciphersToRemove.push(id);
617+
return;
618+
}
619+
620+
// Check if element still contains the cipher text
621+
const text = cipher.element.textContent || '';
622+
if (!text.includes(cipher.encrypted)) {
623+
ciphersToRemove.push(id);
624+
return;
625+
}
626+
627+
// Update positions
628+
const newRects = getCipherRects(cipher.element, cipher.encrypted);
629+
630+
// If no valid rects, element might be hidden/scrolled out
631+
if (newRects.length === 0) {
632+
// Hide underlines but don't remove cipher (element still exists)
633+
cipher.underlines.forEach(u => u.style.display = 'none');
634+
cipher.hitboxes.forEach(h => h.style.display = 'none');
635+
return;
636+
}
637+
638+
// Show and update
639+
cipher.rects = newRects;
640+
cipher.underlines.forEach(u => u.style.display = '');
641+
cipher.hitboxes.forEach(h => h.style.display = '');
642+
renderUnderlines(cipher);
643+
});
644+
645+
// Clean up removed ciphers
646+
ciphersToRemove.forEach(id => {
647+
const cipher = detectedCiphers.get(id);
648+
if (cipher) {
579649
cipher.underlines.forEach(u => u.remove());
580650
cipher.hitboxes.forEach(h => h.remove());
581-
closeBubble(cipher.id);
582-
detectedCiphers.delete(cipher.id);
651+
closeBubble(id);
652+
detectedCiphers.delete(id);
583653
}
584654
});
585655
});
586656
};
587657

588658
window.addEventListener('scroll', updatePositions, { passive: true });
589659
window.addEventListener('resize', updatePositions, { passive: true });
660+
661+
// SPA navigation detection - clear all underlines when URL changes
662+
let lastUrl = window.location.href;
663+
const checkUrlChange = () => {
664+
if (window.location.href !== lastUrl) {
665+
lastUrl = window.location.href;
666+
console.log('🦆 URL changed, clearing all underlines');
667+
clearAllUnderlines();
668+
}
669+
};
670+
671+
// Check on popstate and also periodically (some SPAs don't trigger popstate)
672+
window.addEventListener('popstate', checkUrlChange);
673+
setInterval(checkUrlChange, 500);
674+
675+
// Also observe mutations for content removal
676+
const cleanupObserver = new MutationObserver(() => {
677+
// Trigger position update which handles cleanup
678+
updatePositions();
679+
});
680+
cleanupObserver.observe(document.body, {
681+
childList: true,
682+
subtree: true,
683+
});
684+
}
685+
686+
/**
687+
* Clear all underlines and reset state (for SPA navigation)
688+
*/
689+
function clearAllUnderlines(): void {
690+
detectedCiphers.forEach(cipher => {
691+
cipher.underlines.forEach(u => u.remove());
692+
cipher.hitboxes.forEach(h => h.remove());
693+
});
694+
detectedCiphers.clear();
695+
activeBubbles.forEach(bubble => bubble.frame.remove());
696+
activeBubbles.clear();
697+
hideHoverCard();
698+
decryptionCount = 0;
699+
warningShown = false;
590700
}
591701

592702
/**

src/content/utils.ts

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,63 @@ import { isEditableElement } from '@/utils/helpers';
88

99
/**
1010
* Check if an element lives inside an editable context
11+
*
12+
* Handles complex web apps (Gmail, Twitter, etc.) that use non-standard
13+
* editable patterns like [role="textbox"], custom input components, etc.
1114
*/
1215
export function isWithinEditable(element: HTMLElement): boolean {
1316
if (isEditableElement(element)) return true;
14-
return Boolean(
15-
element.closest('input, textarea, [contenteditable="true"], [contenteditable=""]')
16-
);
17+
18+
// Walk up the tree checking for editable contexts
19+
let node: HTMLElement | null = element;
20+
while (node && node !== document.body) {
21+
const tagName = node.tagName.toLowerCase();
22+
23+
// Standard form elements
24+
if (tagName === 'input' || tagName === 'textarea') {
25+
return true;
26+
}
27+
28+
// Contenteditable
29+
if (node.isContentEditable) {
30+
return true;
31+
}
32+
const contentEditable = node.getAttribute('contenteditable');
33+
if (contentEditable === 'true' || contentEditable === '') {
34+
return true;
35+
}
36+
37+
// ARIA textbox role (Gmail, Twitter compose, etc.)
38+
const role = node.getAttribute('role');
39+
if (role === 'textbox' || role === 'searchbox' || role === 'combobox') {
40+
return true;
41+
}
42+
43+
// Common editable class patterns (Gmail, Outlook, etc.)
44+
const className = node.className?.toLowerCase?.() || '';
45+
if (
46+
className.includes('editable') ||
47+
className.includes('input') ||
48+
className.includes('compose') ||
49+
className.includes('editor') ||
50+
className.includes('search')
51+
) {
52+
// Verify it's actually interactive, not just named similarly
53+
if (node.isContentEditable || node.getAttribute('contenteditable') ||
54+
node.querySelector('input, textarea, [contenteditable]')) {
55+
return true;
56+
}
57+
}
58+
59+
// Data attributes used by some frameworks
60+
if (node.dataset?.editable === 'true' || node.dataset?.editor) {
61+
return true;
62+
}
63+
64+
node = node.parentElement;
65+
}
66+
67+
return false;
1768
}
1869

1970
/**

0 commit comments

Comments
 (0)