1+ "use client" ;
2+
3+ import React , {
4+ createContext ,
5+ useContext ,
6+ useEffect ,
7+ useRef ,
8+ useState ,
9+ useCallback ,
10+ useId ,
11+ useMemo ,
12+ type ReactNode ,
13+ } from "react" ;
14+ import Link from "next/link" ;
15+
16+ type FootnoteData = {
17+ id : number ;
18+ content : ReactNode ;
19+ link ?: string ;
20+ element : HTMLElement ;
21+ } ;
22+
23+ type FootnoteContextType = {
24+ register : ( id : number , data : Omit < FootnoteData , "id" > ) => void ;
25+ unregister : ( id : number ) => void ;
26+ uniqueScopeId : string ;
27+ } ;
28+
29+ const FootnoteContext = createContext < FootnoteContextType | null > ( null ) ;
30+
31+ export function CitationNote ( {
32+ id,
33+ children,
34+ link,
35+ } : {
36+ id : number ;
37+ children : ReactNode ;
38+ link ?: string ;
39+ } ) {
40+ const context = useContext ( FootnoteContext ) ;
41+ const ref = useRef < HTMLElement > ( null ) ;
42+
43+ useEffect ( ( ) => {
44+ if ( context ?. register && ref . current ) {
45+ context . register ( id , { content : children , link, element : ref . current } ) ;
46+ }
47+ return ( ) => {
48+ if ( context ?. unregister ) context . unregister ( id ) ;
49+ } ;
50+ } , [ id , children , link , context ] ) ;
51+
52+ return (
53+ < sup
54+ ref = { ref }
55+ data-footnote-id = { `${ context ?. uniqueScopeId } -${ id } ` }
56+ className = "footnote-ref hidden xl:inline-block cursor-pointer text-muted-foreground hover:text-foreground mx-0.5"
57+ >
58+ { id }
59+ </ sup >
60+ ) ;
61+ }
62+
63+ export function CitationLayout ( {
64+ children,
65+ offset = 0 ,
66+ } : {
67+ children : ReactNode ;
68+ offset ?: number ;
69+ } ) {
70+ const [ footnotes , setFootnotes ] = useState < Map < number , FootnoteData > > ( new Map ( ) ) ;
71+ const [ tipPositions , setTipPositions ] = useState < Record < number , number > > ( { } ) ;
72+
73+ const contentRef = useRef < HTMLDivElement > ( null ) ;
74+ const timeoutRef = useRef < number | null > ( null ) ;
75+ const uniqueScopeId = useId ( ) ;
76+
77+ const register = useCallback ( ( id : number , data : Omit < FootnoteData , "id" > ) => {
78+ setFootnotes ( ( prev ) => {
79+ const existing = prev . get ( id ) ;
80+ if (
81+ existing &&
82+ existing . element === data . element &&
83+ existing . link === data . link &&
84+ existing . content === data . content
85+ ) {
86+ return prev ;
87+ }
88+
89+ const newMap = new Map ( prev ) ;
90+ newMap . set ( id , { id, ...data } ) ;
91+ return newMap ;
92+ } ) ;
93+ } , [ ] ) ;
94+
95+ const unregister = useCallback ( ( id : number ) => {
96+ setFootnotes ( ( prev ) => {
97+ if ( ! prev . has ( id ) ) return prev ;
98+ const newMap = new Map ( prev ) ;
99+ newMap . delete ( id ) ;
100+ return newMap ;
101+ } ) ;
102+ } , [ ] ) ;
103+
104+ const contextValue = useMemo ( ( ) => ( {
105+ register,
106+ unregister,
107+ uniqueScopeId
108+ } ) , [ register , unregister , uniqueScopeId ] ) ;
109+
110+ const positionFootnotes = useCallback ( ( ) => {
111+ const content = contentRef . current ;
112+ if ( ! content || footnotes . size === 0 ) return ;
113+
114+ const newPositions : Record < number , number > = { } ;
115+ const occupiedRanges : { top : number ; bottom : number } [ ] = [ ] ;
116+ const minGap = 10 ;
117+
118+ const sortedNotes = Array . from ( footnotes . values ( ) ) . sort ( ( a , b ) => a . id - b . id ) ;
119+
120+ sortedNotes . forEach ( ( note ) => {
121+ const marker = note . element ;
122+ const tipElement = document . getElementById ( `tip-sidebar-${ uniqueScopeId } -${ note . id } ` ) ;
123+
124+ if ( ! marker || ! tipElement ) return ;
125+
126+ const markerRect = marker . getBoundingClientRect ( ) ;
127+ const contentRect = content . getBoundingClientRect ( ) ;
128+ const tipRect = tipElement . getBoundingClientRect ( ) ;
129+
130+ let top = markerRect . top - contentRect . top ;
131+
132+ let adjusted = true ;
133+ while ( adjusted ) {
134+ adjusted = false ;
135+ for ( const range of occupiedRanges ) {
136+ if (
137+ top < range . bottom + minGap &&
138+ top + tipRect . height + minGap > range . top
139+ ) {
140+ top = range . bottom + minGap ;
141+ adjusted = true ;
142+ break ;
143+ }
144+ }
145+ }
146+
147+ newPositions [ note . id ] = top ;
148+ occupiedRanges . push ( {
149+ top : top ,
150+ bottom : top + tipRect . height ,
151+ } ) ;
152+ } ) ;
153+
154+ setTipPositions ( newPositions ) ;
155+ } , [ footnotes , uniqueScopeId ] ) ;
156+
157+ useEffect ( ( ) => {
158+ const handleUpdate = ( ) => {
159+ if ( timeoutRef . current ) window . clearTimeout ( timeoutRef . current ) ;
160+ timeoutRef . current = window . setTimeout ( positionFootnotes , 100 ) ;
161+ } ;
162+
163+ handleUpdate ( ) ;
164+
165+ window . addEventListener ( "resize" , handleUpdate ) ;
166+ window . addEventListener ( "scroll" , handleUpdate , true ) ;
167+
168+ return ( ) => {
169+ window . removeEventListener ( "resize" , handleUpdate ) ;
170+ window . removeEventListener ( "scroll" , handleUpdate , true ) ;
171+ if ( timeoutRef . current ) window . clearTimeout ( timeoutRef . current ) ;
172+ } ;
173+ } , [ footnotes , positionFootnotes ] ) ;
174+
175+ const footnotesList = Array . from ( footnotes . values ( ) ) . sort ( ( a , b ) => a . id - b . id ) ;
176+
177+ return (
178+ < FootnoteContext . Provider value = { contextValue } >
179+ < div className = "w-full px-6 relative" >
180+ < div className = "mx-auto w-full max-w-4xl relative lg:pr-2" >
181+
182+ < div ref = { contentRef } className = "w-full" >
183+ { children }
184+ </ div >
185+
186+ { footnotesList . length > 0 && (
187+ < div
188+ className = "hidden xl:block absolute top-0 w-16 h-full pointer-events-none pl-8"
189+ style = { { left : "100%" } }
190+ >
191+ { footnotesList . map ( ( note ) => {
192+ const top = tipPositions [ note . id ] ?? 0 ;
193+ const isPositioned = tipPositions [ note . id ] !== undefined ;
194+
195+ return (
196+ < div
197+ key = { note . id }
198+ id = { `tip-sidebar-${ uniqueScopeId } -${ note . id } ` }
199+ className = "absolute w-full text-xs leading-relaxed transition-all duration-300 ease-out pointer-events-auto"
200+ style = { {
201+ top : `${ top } px` ,
202+ opacity : isPositioned ? 1 : 0 ,
203+ } }
204+ >
205+ { note . link ? (
206+ < Link
207+ className = "animated-underline-gray text-wrap text-muted-foreground hover:text-foreground pb-0 block"
208+ href = { note . link }
209+ target = "_blank"
210+ >
211+ < sup className = "mr-1" > { note . id + offset } </ sup >
212+ < span > { note . content } </ span >
213+ </ Link >
214+ ) : (
215+ < span className = "text-muted-foreground block" >
216+ < sup className = "mr-1" > { note . id + offset } </ sup >
217+ < span > { note . content } </ span >
218+ </ span >
219+ ) }
220+ </ div >
221+ ) ;
222+ } ) }
223+ </ div >
224+ ) }
225+ </ div >
226+ </ div >
227+ </ FootnoteContext . Provider >
228+ ) ;
229+ }
0 commit comments