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