diff --git a/app/lish/[did]/[publication]/[rkey]/Blocks/BaseTextBlock.tsx b/app/lish/[did]/[publication]/[rkey]/Blocks/BaseTextBlock.tsx index 65a58881..b372130a 100644 --- a/app/lish/[did]/[publication]/[rkey]/Blocks/BaseTextBlock.tsx +++ b/app/lish/[did]/[publication]/[rkey]/Blocks/BaseTextBlock.tsx @@ -5,6 +5,7 @@ import { RichText, } from "../Blocks/TextBlockCore"; import { ReactNode } from "react"; +import { PublishedFootnoteRefRenderer } from "../Footnotes/PublishedFootnotePopover"; // Re-export RichText for backwards compatibility export { RichText }; @@ -19,6 +20,7 @@ export function BaseTextBlock(props: Omit) { {...props} renderers={{ DidMention: DidMentionWithPopover, + FootnoteRef: PublishedFootnoteRefRenderer, }} footnoteIndexMap={props.footnoteIndexMap} /> diff --git a/app/lish/[did]/[publication]/[rkey]/Footnotes/PublishedFootnotePopover.tsx b/app/lish/[did]/[publication]/[rkey]/Footnotes/PublishedFootnotePopover.tsx new file mode 100644 index 00000000..c7dc68c2 --- /dev/null +++ b/app/lish/[did]/[publication]/[rkey]/Footnotes/PublishedFootnotePopover.tsx @@ -0,0 +1,163 @@ +"use client"; + +import { useEffect, useRef, useState, useCallback, ReactNode } from "react"; +import { create } from "zustand"; +import { TextBlockCore } from "../Blocks/TextBlockCore"; +import { PubLeafletRichtextFacet } from "lexicons/api"; + +type PublishedFootnoteData = { + footnoteId: string; + index: number; + contentPlaintext: string; + contentFacets?: PubLeafletRichtextFacet.Main[]; +}; + +type PopoverState = { + activeFootnoteId: string | null; + anchorElement: HTMLElement | null; + open: (footnoteId: string, anchor: HTMLElement) => void; + close: () => void; +}; + +export const usePublishedFootnotePopoverStore = create( + (set) => ({ + activeFootnoteId: null, + anchorElement: null, + open: (footnoteId, anchor) => + set({ activeFootnoteId: footnoteId, anchorElement: anchor }), + close: () => set({ activeFootnoteId: null, anchorElement: null }), + }), +); + +export function PublishedFootnoteRefRenderer(props: { + footnoteId: string; + index: number; + children: ReactNode; +}) { + let ref = useRef(null); + return ( + { + e.preventDefault(); + let store = usePublishedFootnotePopoverStore.getState(); + if (store.activeFootnoteId === props.footnoteId) { + store.close(); + } else { + store.open(props.footnoteId, e.currentTarget); + } + }} + > + {props.index} + + ); +} + +export function PublishedFootnotePopover(props: { + footnotes: PublishedFootnoteData[]; +}) { + let { activeFootnoteId, anchorElement, close } = + usePublishedFootnotePopoverStore(); + let popoverRef = useRef(null); + let [position, setPosition] = useState<{ + top: number; + left: number; + arrowLeft: number; + } | null>(null); + + let footnote = props.footnotes.find( + (fn) => fn.footnoteId === activeFootnoteId, + ); + + let updatePosition = useCallback(() => { + if (!anchorElement || !popoverRef.current) return; + + let anchorRect = anchorElement.getBoundingClientRect(); + let popoverWidth = popoverRef.current.offsetWidth; + let popoverHeight = popoverRef.current.offsetHeight; + + let top = anchorRect.top - popoverHeight - 8; + let left = anchorRect.left + anchorRect.width / 2 - popoverWidth / 2; + + let padding = 12; + left = Math.max( + padding, + Math.min(left, window.innerWidth - popoverWidth - padding), + ); + + let arrowLeft = anchorRect.left + anchorRect.width / 2 - left; + + if (top < padding) { + top = anchorRect.bottom + 8; + } + + setPosition({ top, left, arrowLeft }); + }, [anchorElement]); + + useEffect(() => { + if (!activeFootnoteId || !anchorElement) { + setPosition(null); + return; + } + + requestAnimationFrame(updatePosition); + + let handleClickOutside = (e: Event) => { + let target = e.target as Node; + if ( + popoverRef.current && + !popoverRef.current.contains(target) && + !anchorElement.contains(target) + ) { + close(); + } + }; + + let handleScroll = () => close(); + + document.addEventListener("mousedown", handleClickOutside); + document.addEventListener("touchstart", handleClickOutside); + window.addEventListener("scroll", handleScroll, true); + window.addEventListener("resize", close); + + return () => { + document.removeEventListener("mousedown", handleClickOutside); + document.removeEventListener("touchstart", handleClickOutside); + window.removeEventListener("scroll", handleScroll, true); + window.removeEventListener("resize", close); + }; + }, [activeFootnoteId, anchorElement, close, updatePosition]); + + if (!activeFootnoteId || !footnote) return null; + + return ( +
+
+ + {footnote.index} + +
+ {footnote.contentPlaintext ? ( + + ) : ( + Empty footnote + )} +
+
+
+ ); +} diff --git a/app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx b/app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx index 87658690..159300e3 100644 --- a/app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx +++ b/app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx @@ -28,6 +28,7 @@ import { PublishedFootnoteSection, } from "./Footnotes/PublishedFootnotes"; import { PublishedFootnoteSideColumn } from "./Footnotes/PublishedFootnoteSideColumn"; +import { PublishedFootnotePopover } from "./Footnotes/PublishedFootnotePopover"; export function LinearDocumentPage({ blocks, @@ -111,6 +112,7 @@ export function LinearDocumentPage({ /> {!hasPageBackground &&
} + ); } diff --git a/components/Blocks/TextBlock/index.tsx b/components/Blocks/TextBlock/index.tsx index f42233a2..dde9c961 100644 --- a/components/Blocks/TextBlock/index.tsx +++ b/components/Blocks/TextBlock/index.tsx @@ -25,6 +25,7 @@ import { useLeafletPublicationData } from "components/PageSWRDataProvider"; import { DotLoader } from "components/utils/DotLoader"; import { useMountProsemirror } from "./mountProsemirror"; import { schema } from "./schema"; +import { useFootnotePopoverStore } from "components/Footnotes/FootnotePopover"; import { Mention, MentionAutocomplete } from "components/Mention"; import { addMentionToEditor } from "app/[leaflet_id]/publish/BskyPostEditorProsemirror"; @@ -159,6 +160,19 @@ export function RenderedTextBlock(props: { return (
{ + let target = e.target as HTMLElement; + let footnoteRef = target.closest(".footnote-ref") as HTMLElement | null; + if (!footnoteRef) return; + let footnoteID = footnoteRef.dataset.footnoteId; + if (!footnoteID) return; + let store = useFootnotePopoverStore.getState(); + if (store.activeFootnoteID === footnoteID) { + store.close(); + } else { + store.open(footnoteID, footnoteRef); + } + }} className={` ${alignmentClass} ${props.type === "blockquote" ? (props.previousBlock?.type === "blockquote" ? `blockquote pt-3 ` : "blockquote") : ""}