@@ -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 */
326329function 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/**
0 commit comments