1- import React , { createContext , useContext , useMemo } from 'react' ;
1+ import React , { createContext , useContext , useRef , useState , useCallback , useMemo } from 'react' ;
22import type { NodeAttribution } from '../../services/attribution' ;
33import type { SerializableSourceInfo , RustFileInfo } from '@quarto/pandoc-types' ;
44import { SourceInfoReconstructor } from '@quarto/annotated-qmd' ;
55import type { SourceContext } from '@quarto/pandoc-types' ;
66import { AttributionContext } from '../../hooks/useAttribution' ;
7- import { getNodeAttribution , buildByteToCharMap } from '../../services/attribution' ;
7+ import { getNodeAttribution } from '../../services/attribution' ;
88
99// Context for unified component registry
1010const RegistryContext = createContext < {
@@ -561,30 +561,94 @@ const AstRenderer = ({ ast, onNavigateToDocument, setAst }: {
561561 sourceContext ,
562562 ) ;
563563
564- const byteToChar = buildByteToCharMap ( attributionCtx . sourceText ) ;
564+ // Use byteToCharMap from context (already computed in useAttribution hook)
565+ const byteToChar = attributionCtx . byteToCharMap ;
566+
567+ // Cache node attribution results — invalidated automatically when
568+ // this useMemo recomputes (new astContext or attributionCtx)
569+ const cache = new Map < number , NodeAttribution | null > ( ) ;
565570
566571 return {
567- getNodeAttribution : ( sourceInfoId : number ) =>
568- getNodeAttribution (
572+ getNodeAttribution : ( sourceInfoId : number ) => {
573+ const cached = cache . get ( sourceInfoId ) ;
574+ if ( cached !== undefined ) return cached ;
575+ const result = getNodeAttribution (
569576 sourceInfoId ,
570577 reconstructor ,
571578 attributionCtx . attributionMap ,
572579 byteToChar ,
573580 attributionCtx . identities ,
574- ) ,
581+ ) ;
582+ cache . set ( sourceInfoId , result ) ;
583+ return result ;
584+ } ,
575585 } ;
576586 } catch {
577587 return null ;
578588 }
579589 } , [ astContext , attributionCtx ] ) ;
580590
591+ // Use internally-computed value, falling back to any externally-provided context
592+ const externalNodeAttr = useContext ( NodeAttributionContext ) ;
593+ const effectiveNodeAttr = nodeAttributionValue ?? externalNodeAttr ;
594+
595+ // Ref to avoid stale closures in event handlers
596+ const effectiveNodeAttrRef = useRef ( effectiveNodeAttr ) ;
597+ effectiveNodeAttrRef . current = effectiveNodeAttr ;
598+
599+ // Hover state for the single floating attribution badge
600+ const [ hoveredAttr , setHoveredAttr ] = useState < {
601+ attr : NodeAttribution ;
602+ rect : DOMRect ;
603+ } | null > ( null ) ;
604+
605+ // Event-delegated hover: one handler on the container instead of N on each node
606+ const handleMouseOver = useCallback ( ( e : React . MouseEvent ) => {
607+ const ctx = effectiveNodeAttrRef . current ;
608+ if ( ! ctx ) return ;
609+ const target = e . target as HTMLElement ;
610+ const wrap = target . closest ( '.q2-attr-wrap[data-sid]' ) as HTMLElement | null ;
611+ if ( ! wrap ) {
612+ setHoveredAttr ( null ) ;
613+ return ;
614+ }
615+ const sid = Number ( wrap . getAttribute ( 'data-sid' ) ) ;
616+ if ( Number . isNaN ( sid ) ) return ;
617+ const attr = ctx . getNodeAttribution ( sid ) ;
618+ if ( attr ) {
619+ setHoveredAttr ( { attr, rect : wrap . getBoundingClientRect ( ) } ) ;
620+ }
621+ } , [ ] ) ;
622+
623+ const handleMouseOut = useCallback ( ( e : React . MouseEvent ) => {
624+ const related = e . relatedTarget as HTMLElement | null ;
625+ if ( ! related ?. closest ?.( '.q2-attr-wrap[data-sid]' ) ) {
626+ setHoveredAttr ( null ) ;
627+ }
628+ } , [ ] ) ;
629+
581630 const tree = (
582- < div className = "pandoc-content-debug" style = { { padding : '20px' , fontSize : '16px' } } >
631+ < div
632+ className = "pandoc-content-debug"
633+ style = { { padding : '20px' , fontSize : '16px' } }
634+ onMouseOver = { effectiveNodeAttr ? handleMouseOver : undefined }
635+ onMouseOut = { effectiveNodeAttr ? handleMouseOut : undefined }
636+ >
583637 { renderChildren ( {
584638 node : ast as any ,
585639 setLocalAst : setAst as any ,
586640 onNavigateToDocument
587641 } ) }
642+ { hoveredAttr && (
643+ < AttributionBadge
644+ attr = { hoveredAttr . attr }
645+ style = { {
646+ position : 'fixed' ,
647+ top : hoveredAttr . rect . bottom + 2 ,
648+ left : hoveredAttr . rect . left ,
649+ } }
650+ />
651+ ) }
588652 </ div >
589653 ) ;
590654
@@ -595,7 +659,9 @@ const AstRenderer = ({ ast, onNavigateToDocument, setAst }: {
595659 < style > { attributionStyles } </ style >
596660 { tree }
597661 </ NodeAttributionContext . Provider >
598- ) : tree ;
662+ ) : (
663+ effectiveNodeAttr ? < > < style > { attributionStyles } </ style > { tree } </ > : tree
664+ ) ;
599665} ;
600666
601667/**
@@ -629,9 +695,13 @@ function formatRelativeTime(timestamp: number): string {
629695}
630696
631697/** Styled tooltip that appears on hover, colored to match the author */
632- function AttributionBadge ( { attr } : { attr : { name : string ; time : number ; color : string } } ) {
698+ function AttributionBadge ( { attr, style } : {
699+ attr : { name : string ; time : number ; color : string } ;
700+ style ?: React . CSSProperties ;
701+ } ) {
633702 return < span className = "q2-attr-badge" style = { {
634703 '--attr-color' : attr . color ,
704+ ...style ,
635705 } as React . CSSProperties } >
636706 < span className = "q2-attr-badge-dot" style = { { backgroundColor : attr . color } } />
637707 { attr . name } < span className = "q2-attr-badge-time" > { formatRelativeTime ( attr . time ) } </ span >
@@ -642,10 +712,7 @@ function AttributionBadge({ attr }: { attr: { name: string; time: number; color:
642712const attributionStyles = `
643713 .q2-attr-wrap { position: relative; }
644714 .q2-attr-badge {
645- display: none;
646- position: absolute;
647- bottom: -20px;
648- left: 0;
715+ display: inline-block;
649716 z-index: 10;
650717 font-size: 10px;
651718 line-height: 1;
@@ -670,7 +737,6 @@ const attributionStyles = `
670737 font-weight: 400;
671738 opacity: 0.7;
672739 }
673- .q2-attr-wrap:hover > .q2-attr-badge { display: inline-block; }
674740` ;
675741
676742const Node = ( {
@@ -701,8 +767,11 @@ const Node = ({
701767 if ( ! BlockComponent ) {
702768 return < div style = { blockStyle } > < strong > Block wrapper not registered</ strong > </ div > ;
703769 }
704- return < div className = { attr ? 'q2-attr-wrap' : undefined } style = { attr ? { color : attr . color } : undefined } >
705- { attr && < AttributionBadge attr = { attr } /> }
770+ return < div
771+ className = { attr ? 'q2-attr-wrap' : undefined }
772+ data-sid = { attr ? sourceInfoId : undefined }
773+ style = { attr ? { color : attr . color } : undefined }
774+ >
706775 < BlockComponent
707776 node = { node as BlockNode }
708777 onNavigateToDocument = { onNavigateToDocument }
@@ -714,8 +783,11 @@ const Node = ({
714783 if ( ! InlineComponent ) {
715784 return < span style = { inlineStyle } > < strong > Inline wrapper not registered</ strong > </ span > ;
716785 }
717- return < span className = { attr ? 'q2-attr-wrap' : undefined } style = { attr ? { color : attr . color } : undefined } >
718- { attr && < AttributionBadge attr = { attr } /> }
786+ return < span
787+ className = { attr ? 'q2-attr-wrap' : undefined }
788+ data-sid = { attr ? sourceInfoId : undefined }
789+ style = { attr ? { color : attr . color } : undefined }
790+ >
719791 < InlineComponent
720792 node = { node as InlineNode }
721793 onNavigateToDocument = { onNavigateToDocument }
0 commit comments