a tool for shared writing and social publishing

open footnote popover

+181
+2
app/lish/[did]/[publication]/[rkey]/Blocks/BaseTextBlock.tsx
··· 5 5 RichText, 6 6 } from "../Blocks/TextBlockCore"; 7 7 import { ReactNode } from "react"; 8 + import { PublishedFootnoteRefRenderer } from "../Footnotes/PublishedFootnotePopover"; 8 9 9 10 // Re-export RichText for backwards compatibility 10 11 export { RichText }; ··· 19 20 {...props} 20 21 renderers={{ 21 22 DidMention: DidMentionWithPopover, 23 + FootnoteRef: PublishedFootnoteRefRenderer, 22 24 }} 23 25 footnoteIndexMap={props.footnoteIndexMap} 24 26 />
+163
app/lish/[did]/[publication]/[rkey]/Footnotes/PublishedFootnotePopover.tsx
··· 1 + "use client"; 2 + 3 + import { useEffect, useRef, useState, useCallback, ReactNode } from "react"; 4 + import { create } from "zustand"; 5 + import { TextBlockCore } from "../Blocks/TextBlockCore"; 6 + import { PubLeafletRichtextFacet } from "lexicons/api"; 7 + 8 + type PublishedFootnoteData = { 9 + footnoteId: string; 10 + index: number; 11 + contentPlaintext: string; 12 + contentFacets?: PubLeafletRichtextFacet.Main[]; 13 + }; 14 + 15 + type PopoverState = { 16 + activeFootnoteId: string | null; 17 + anchorElement: HTMLElement | null; 18 + open: (footnoteId: string, anchor: HTMLElement) => void; 19 + close: () => void; 20 + }; 21 + 22 + export const usePublishedFootnotePopoverStore = create<PopoverState>( 23 + (set) => ({ 24 + activeFootnoteId: null, 25 + anchorElement: null, 26 + open: (footnoteId, anchor) => 27 + set({ activeFootnoteId: footnoteId, anchorElement: anchor }), 28 + close: () => set({ activeFootnoteId: null, anchorElement: null }), 29 + }), 30 + ); 31 + 32 + export function PublishedFootnoteRefRenderer(props: { 33 + footnoteId: string; 34 + index: number; 35 + children: ReactNode; 36 + }) { 37 + let ref = useRef<HTMLElement>(null); 38 + return ( 39 + <sup 40 + ref={ref} 41 + className="text-accent-contrast cursor-pointer" 42 + id={`fnref-${props.footnoteId}`} 43 + onClick={(e) => { 44 + e.preventDefault(); 45 + let store = usePublishedFootnotePopoverStore.getState(); 46 + if (store.activeFootnoteId === props.footnoteId) { 47 + store.close(); 48 + } else { 49 + store.open(props.footnoteId, e.currentTarget); 50 + } 51 + }} 52 + > 53 + {props.index} 54 + </sup> 55 + ); 56 + } 57 + 58 + export function PublishedFootnotePopover(props: { 59 + footnotes: PublishedFootnoteData[]; 60 + }) { 61 + let { activeFootnoteId, anchorElement, close } = 62 + usePublishedFootnotePopoverStore(); 63 + let popoverRef = useRef<HTMLDivElement>(null); 64 + let [position, setPosition] = useState<{ 65 + top: number; 66 + left: number; 67 + arrowLeft: number; 68 + } | null>(null); 69 + 70 + let footnote = props.footnotes.find( 71 + (fn) => fn.footnoteId === activeFootnoteId, 72 + ); 73 + 74 + let updatePosition = useCallback(() => { 75 + if (!anchorElement || !popoverRef.current) return; 76 + 77 + let anchorRect = anchorElement.getBoundingClientRect(); 78 + let popoverWidth = popoverRef.current.offsetWidth; 79 + let popoverHeight = popoverRef.current.offsetHeight; 80 + 81 + let top = anchorRect.top - popoverHeight - 8; 82 + let left = anchorRect.left + anchorRect.width / 2 - popoverWidth / 2; 83 + 84 + let padding = 12; 85 + left = Math.max( 86 + padding, 87 + Math.min(left, window.innerWidth - popoverWidth - padding), 88 + ); 89 + 90 + let arrowLeft = anchorRect.left + anchorRect.width / 2 - left; 91 + 92 + if (top < padding) { 93 + top = anchorRect.bottom + 8; 94 + } 95 + 96 + setPosition({ top, left, arrowLeft }); 97 + }, [anchorElement]); 98 + 99 + useEffect(() => { 100 + if (!activeFootnoteId || !anchorElement) { 101 + setPosition(null); 102 + return; 103 + } 104 + 105 + requestAnimationFrame(updatePosition); 106 + 107 + let handleClickOutside = (e: Event) => { 108 + let target = e.target as Node; 109 + if ( 110 + popoverRef.current && 111 + !popoverRef.current.contains(target) && 112 + !anchorElement.contains(target) 113 + ) { 114 + close(); 115 + } 116 + }; 117 + 118 + let handleScroll = () => close(); 119 + 120 + document.addEventListener("mousedown", handleClickOutside); 121 + document.addEventListener("touchstart", handleClickOutside); 122 + window.addEventListener("scroll", handleScroll, true); 123 + window.addEventListener("resize", close); 124 + 125 + return () => { 126 + document.removeEventListener("mousedown", handleClickOutside); 127 + document.removeEventListener("touchstart", handleClickOutside); 128 + window.removeEventListener("scroll", handleScroll, true); 129 + window.removeEventListener("resize", close); 130 + }; 131 + }, [activeFootnoteId, anchorElement, close, updatePosition]); 132 + 133 + if (!activeFootnoteId || !footnote) return null; 134 + 135 + return ( 136 + <div 137 + ref={popoverRef} 138 + 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)]" 139 + style={{ 140 + top: position?.top ?? -9999, 141 + left: position?.left ?? -9999, 142 + visibility: position ? "visible" : "hidden", 143 + }} 144 + > 145 + <div className="flex gap-2 items-start text-sm"> 146 + <span className="text-accent-contrast font-bold shrink-0"> 147 + {footnote.index} 148 + </span> 149 + <div className="min-w-0"> 150 + {footnote.contentPlaintext ? ( 151 + <TextBlockCore 152 + plaintext={footnote.contentPlaintext} 153 + facets={footnote.contentFacets} 154 + index={[]} 155 + /> 156 + ) : ( 157 + <span className="italic text-tertiary">Empty footnote</span> 158 + )} 159 + </div> 160 + </div> 161 + </div> 162 + ); 163 + }
+2
app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx
··· 28 28 PublishedFootnoteSection, 29 29 } from "./Footnotes/PublishedFootnotes"; 30 30 import { PublishedFootnoteSideColumn } from "./Footnotes/PublishedFootnoteSideColumn"; 31 + import { PublishedFootnotePopover } from "./Footnotes/PublishedFootnotePopover"; 31 32 32 33 export function LinearDocumentPage({ 33 34 blocks, ··· 111 112 /> 112 113 {!hasPageBackground && <div className={`spacer h-8 w-full`} />} 113 114 </PageWrapper> 115 + <PublishedFootnotePopover footnotes={footnotes} /> 114 116 </> 115 117 ); 116 118 }
+14
components/Blocks/TextBlock/index.tsx
··· 25 25 import { DotLoader } from "components/utils/DotLoader"; 26 26 import { useMountProsemirror } from "./mountProsemirror"; 27 27 import { schema } from "./schema"; 28 + import { useFootnotePopoverStore } from "components/Footnotes/FootnotePopover"; 28 29 29 30 import { Mention, MentionAutocomplete } from "components/Mention"; 30 31 import { addMentionToEditor } from "app/[leaflet_id]/publish/BskyPostEditorProsemirror"; ··· 159 160 return ( 160 161 <div 161 162 style={{ wordBreak: "break-word" }} // better than tailwind break-all! 163 + onClick={(e) => { 164 + let target = e.target as HTMLElement; 165 + let footnoteRef = target.closest(".footnote-ref") as HTMLElement | null; 166 + if (!footnoteRef) return; 167 + let footnoteID = footnoteRef.dataset.footnoteId; 168 + if (!footnoteID) return; 169 + let store = useFootnotePopoverStore.getState(); 170 + if (store.activeFootnoteID === footnoteID) { 171 + store.close(); 172 + } else { 173 + store.open(footnoteID, footnoteRef); 174 + } 175 + }} 162 176 className={` 163 177 ${alignmentClass} 164 178 ${props.type === "blockquote" ? (props.previousBlock?.type === "blockquote" ? `blockquote pt-3 ` : "blockquote") : ""}