a tool for shared writing and social publishing
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}