a tool for shared writing and social publishing
at main 152 lines 5.0 kB view raw
1"use client"; 2 3import { useEffect, useRef, useState, useCallback, useMemo } from "react"; 4import { create } from "zustand"; 5import { useFootnoteContext } from "./FootnoteContext"; 6import { FootnoteEditor } from "./FootnoteEditor"; 7import { useReplicache } from "src/replicache"; 8import { useEntitySetContext } from "components/EntitySetProvider"; 9import { deleteFootnoteFromBlock } from "./deleteFootnoteFromBlock"; 10 11type FootnotePopoverState = { 12 activeFootnoteID: string | null; 13 anchorElement: HTMLElement | null; 14 open: (footnoteID: string, anchor: HTMLElement) => void; 15 close: () => void; 16}; 17 18export const useFootnotePopoverStore = create<FootnotePopoverState>((set) => ({ 19 activeFootnoteID: null, 20 anchorElement: null, 21 open: (footnoteID, anchor) => 22 set({ activeFootnoteID: footnoteID, anchorElement: anchor }), 23 close: () => set({ activeFootnoteID: null, anchorElement: null }), 24})); 25 26export function FootnotePopover() { 27 let { activeFootnoteID, anchorElement, close } = useFootnotePopoverStore(); 28 let { footnotes } = useFootnoteContext(); 29 let { permissions } = useEntitySetContext(); 30 let rep = useReplicache(); 31 let popoverRef = useRef<HTMLDivElement>(null); 32 let [position, setPosition] = useState<{ 33 top: number; 34 left: number; 35 arrowLeft: number; 36 } | null>(null); 37 38 let footnote = footnotes.find( 39 (fn) => fn.footnoteEntityID === activeFootnoteID, 40 ); 41 42 // Compute the displayed index from DOM order (matching CSS counters) 43 // rather than the data model order, which may differ if footnotes 44 // were inserted out of order within a block. 45 let displayIndex = useMemo(() => { 46 if (!anchorElement || !footnote) return footnote?.index ?? 0; 47 let container = anchorElement.closest('.footnote-scope'); 48 if (!container) return footnote.index; 49 let allRefs = Array.from(container.querySelectorAll(".footnote-ref")); 50 let pos = allRefs.indexOf(anchorElement); 51 return pos >= 0 ? pos + 1 : footnote.index; 52 }, [anchorElement, footnote]); 53 54 let updatePosition = useCallback(() => { 55 if (!anchorElement || !popoverRef.current) return; 56 57 let anchorRect = anchorElement.getBoundingClientRect(); 58 let popoverWidth = popoverRef.current.offsetWidth; 59 let popoverHeight = popoverRef.current.offsetHeight; 60 61 // Position above the anchor by default, fall back to below 62 let top = anchorRect.top - popoverHeight - 8; 63 let left = anchorRect.left + anchorRect.width / 2 - popoverWidth / 2; 64 65 // Clamp horizontal position 66 let padding = 12; 67 left = Math.max( 68 padding, 69 Math.min(left, window.innerWidth - popoverWidth - padding), 70 ); 71 72 // Arrow position relative to popover 73 let arrowLeft = anchorRect.left + anchorRect.width / 2 - left; 74 75 // If not enough room above, show below 76 if (top < padding) { 77 top = anchorRect.bottom + 8; 78 } 79 80 setPosition({ top, left, arrowLeft }); 81 }, [anchorElement]); 82 83 useEffect(() => { 84 if (!activeFootnoteID || !anchorElement) { 85 setPosition(null); 86 return; 87 } 88 89 // Delay to let the popover render so we can measure it 90 requestAnimationFrame(updatePosition); 91 92 let handleClickOutside = (e: Event) => { 93 let target = e.target as Node; 94 if ( 95 popoverRef.current && 96 !popoverRef.current.contains(target) && 97 !anchorElement.contains(target) 98 ) { 99 close(); 100 } 101 }; 102 103 let handleScroll = () => close(); 104 105 document.addEventListener("mousedown", handleClickOutside); 106 document.addEventListener("touchstart", handleClickOutside); 107 // Close on scroll of any scroll container 108 let scrollWrapper = anchorElement.closest(".pageScrollWrapper"); 109 scrollWrapper?.addEventListener("scroll", handleScroll); 110 window.addEventListener("resize", close); 111 112 return () => { 113 document.removeEventListener("mousedown", handleClickOutside); 114 document.removeEventListener("touchstart", handleClickOutside); 115 scrollWrapper?.removeEventListener("scroll", handleScroll); 116 window.removeEventListener("resize", close); 117 }; 118 }, [activeFootnoteID, anchorElement, close, updatePosition]); 119 120 if (!activeFootnoteID || !footnote) return null; 121 122 return ( 123 <div 124 ref={popoverRef} 125 className="footnote-popover fixed z-50 bg-bg-page border border-border rounded-lg shadow-md px-3 py-2 w-[min(calc(100vw-24px),320px)]" 126 style={{ 127 top: position?.top ?? -9999, 128 left: position?.left ?? -9999, 129 visibility: position ? "visible" : "hidden", 130 }} 131 > 132 <FootnoteEditor 133 footnoteEntityID={footnote.footnoteEntityID} 134 index={displayIndex} 135 editable={permissions.write} 136 autoFocus={permissions.write} 137 onDelete={ 138 permissions.write 139 ? () => { 140 deleteFootnoteFromBlock( 141 footnote.footnoteEntityID, 142 footnote.blockID, 143 rep.rep, 144 ); 145 close(); 146 } 147 : undefined 148 } 149 /> 150 </div> 151 ); 152}