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
render footnotes properly in published page
awarm.space
1 week ago
16f80527
713d14b4
+280
-184
5 changed files
expand all
collapse all
unified
split
app
lish
[did]
[publication]
[rkey]
Footnotes
PublishedFootnoteSideColumn.tsx
LinearDocumentPage.tsx
PostPages.tsx
components
Footnotes
FootnoteSideColumn.tsx
FootnoteSideColumnLayout.tsx
+58
app/lish/[did]/[publication]/[rkey]/Footnotes/PublishedFootnoteSideColumn.tsx
···
1
1
+
"use client";
2
2
+
3
3
+
import { useCallback } from "react";
4
4
+
import { PublishedFootnote } from "./PublishedFootnotes";
5
5
+
import { TextBlockCore } from "../Blocks/TextBlockCore";
6
6
+
import { FootnoteSideColumnLayout } from "components/Footnotes/FootnoteSideColumnLayout";
7
7
+
8
8
+
type PublishedFootnoteItem = PublishedFootnote & {
9
9
+
id: string;
10
10
+
};
11
11
+
12
12
+
export function PublishedFootnoteSideColumn(props: {
13
13
+
footnotes: PublishedFootnote[];
14
14
+
}) {
15
15
+
let items: PublishedFootnoteItem[] = props.footnotes.map((fn) => ({
16
16
+
...fn,
17
17
+
id: fn.footnoteId,
18
18
+
}));
19
19
+
20
20
+
let getAnchorSelector = useCallback(
21
21
+
(item: PublishedFootnoteItem) => `#fnref-${item.id}`,
22
22
+
[],
23
23
+
);
24
24
+
25
25
+
let renderItem = useCallback(
26
26
+
(item: PublishedFootnoteItem & { top: number }) => (
27
27
+
<>
28
28
+
<a
29
29
+
href={`#fnref-${item.footnoteId}`}
30
30
+
className="text-accent-contrast font-medium text-xs no-underline hover:underline"
31
31
+
>
32
32
+
{item.index}.
33
33
+
</a>{" "}
34
34
+
<span className="text-secondary">
35
35
+
{item.contentPlaintext ? (
36
36
+
<TextBlockCore
37
37
+
plaintext={item.contentPlaintext}
38
38
+
facets={item.contentFacets}
39
39
+
index={[]}
40
40
+
/>
41
41
+
) : (
42
42
+
<span className="italic text-tertiary">Empty footnote</span>
43
43
+
)}
44
44
+
</span>
45
45
+
</>
46
46
+
),
47
47
+
[],
48
48
+
);
49
49
+
50
50
+
return (
51
51
+
<FootnoteSideColumnLayout
52
52
+
items={items}
53
53
+
visible={props.footnotes.length > 0}
54
54
+
getAnchorSelector={getAnchorSelector}
55
55
+
renderItem={renderItem}
56
56
+
/>
57
57
+
);
58
58
+
}
+6
app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx
···
27
27
buildFootnoteIndexMap,
28
28
PublishedFootnoteSection,
29
29
} from "./Footnotes/PublishedFootnotes";
30
30
+
import { PublishedFootnoteSideColumn } from "./Footnotes/PublishedFootnoteSideColumn";
30
31
31
32
export function LinearDocumentPage({
32
33
blocks,
···
69
70
!!drawer && (pageId ? drawer.pageId === pageId : !drawer.pageId)
70
71
}
71
72
pageOptions={pageOptions}
73
73
+
footnoteSideColumn={
74
74
+
!props.hasContentToRight ? (
75
75
+
<PublishedFootnoteSideColumn footnotes={footnotes} />
76
76
+
) : undefined
77
77
+
}
72
78
>
73
79
{!isSubpage && profile && (
74
80
<PostHeader
+13
-2
app/lish/[did]/[publication]/[rkey]/PostPages.tsx
···
185
185
pageId?: string;
186
186
pageOptions?: React.ReactNode;
187
187
allPages: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[];
188
188
+
hasContentToRight?: boolean;
188
189
};
189
190
190
191
// Component that renders either Canvas or Linear page based on page type
···
286
287
<>
287
288
{!sharedProps.fullPageScroll && <BookendSpacer />}
288
289
289
289
-
<PageRenderer page={firstPage} {...sharedProps} />
290
290
+
<PageRenderer
291
291
+
page={firstPage}
292
292
+
{...sharedProps}
293
293
+
hasContentToRight={
294
294
+
openPageIds.length > 0 || !!(drawer && !drawer.pageId)
295
295
+
}
296
296
+
/>
290
297
291
298
{drawer && !drawer.pageId && (
292
299
<InteractionDrawer
···
306
313
/>
307
314
)}
308
315
309
309
-
{openPageIds.map((openPage) => {
316
316
+
{openPageIds.map((openPage, openPageIndex) => {
310
317
const pageKey = getPageKey(openPage);
311
318
312
319
// Handle thread pages
···
372
379
{...sharedProps}
373
380
fullPageScroll={false}
374
381
pageId={page.id}
382
382
+
hasContentToRight={
383
383
+
openPageIndex < openPageIds.length - 1 ||
384
384
+
!!(drawer && drawer.pageId === page.id)
385
385
+
}
375
386
pageOptions={
376
387
<PageOptions
377
388
onClick={() => closePage(openPage)}
+36
-182
components/Footnotes/FootnoteSideColumn.tsx
···
1
1
-
import { useEffect, useRef, useState, useCallback } from "react";
1
1
+
import { useCallback } from "react";
2
2
import { useFootnoteContext } from "./FootnoteContext";
3
3
import { FootnoteEditor } from "./FootnoteEditor";
4
4
import { useReplicache } from "src/replicache";
5
5
import { useEntitySetContext } from "components/EntitySetProvider";
6
6
import { deleteFootnoteFromBlock } from "./deleteFootnoteFromBlock";
7
7
+
import { FootnoteSideColumnLayout } from "./FootnoteSideColumnLayout";
7
8
8
8
-
type PositionedFootnote = {
9
9
+
type EditorFootnoteItem = {
10
10
+
id: string;
11
11
+
index: number;
9
12
footnoteEntityID: string;
10
13
blockID: string;
11
11
-
index: number;
12
12
-
top: number;
13
14
};
14
14
-
15
15
-
const GAP = 4;
16
15
17
16
export function FootnoteSideColumn(props: {
18
17
pageEntityID: string;
···
21
20
let { footnotes } = useFootnoteContext();
22
21
let { permissions } = useEntitySetContext();
23
22
let rep = useReplicache();
24
24
-
let containerRef = useRef<HTMLDivElement>(null);
25
25
-
let innerRef = useRef<HTMLDivElement>(null);
26
26
-
let [positions, setPositions] = useState<PositionedFootnote[]>([]);
27
27
-
let [scrollOffset, setScrollOffset] = useState(0);
28
28
-
29
29
-
let calculatePositions = useCallback(() => {
30
30
-
let container = containerRef.current;
31
31
-
let inner = innerRef.current;
32
32
-
if (!container || !inner || footnotes.length === 0) {
33
33
-
setPositions([]);
34
34
-
return;
35
35
-
}
36
36
-
37
37
-
let scrollWrapper = container.closest(".pageWrapper")
38
38
-
?.querySelector(".pageScrollWrapper") as HTMLElement | null;
39
39
-
if (!scrollWrapper) return;
40
40
-
41
41
-
let scrollTop = scrollWrapper.scrollTop;
42
42
-
let scrollWrapperRect = scrollWrapper.getBoundingClientRect();
43
43
-
setScrollOffset(scrollTop);
44
44
-
45
45
-
// Phase 1: Batch read — measure all anchor positions and item heights
46
46
-
let measurements: {
47
47
-
footnoteEntityID: string;
48
48
-
blockID: string;
49
49
-
index: number;
50
50
-
anchorTop: number;
51
51
-
height: number;
52
52
-
}[] = [];
53
53
-
54
54
-
for (let fn of footnotes) {
55
55
-
let supEl = scrollWrapper.querySelector(
56
56
-
`.footnote-ref[data-footnote-id="${fn.footnoteEntityID}"]`,
57
57
-
) as HTMLElement | null;
58
58
-
if (!supEl) continue;
59
59
-
60
60
-
let supRect = supEl.getBoundingClientRect();
61
61
-
let anchorTop = supRect.top - scrollWrapperRect.top + scrollTop;
62
62
-
63
63
-
// Measure actual rendered height of the side item element
64
64
-
let itemEl = inner.querySelector(
65
65
-
`[data-footnote-side-id="${fn.footnoteEntityID}"]`,
66
66
-
) as HTMLElement | null;
67
67
-
let height = itemEl ? itemEl.offsetHeight : 54; // fallback for first render
68
23
69
69
-
measurements.push({
70
70
-
footnoteEntityID: fn.footnoteEntityID,
71
71
-
blockID: fn.blockID,
72
72
-
index: fn.index,
73
73
-
anchorTop,
74
74
-
height,
75
75
-
});
76
76
-
}
77
77
-
78
78
-
// Phase 2: Resolve collisions using measured heights
79
79
-
let resolved: PositionedFootnote[] = [];
80
80
-
let nextAvailableTop = 0;
81
81
-
for (let m of measurements) {
82
82
-
let top = Math.max(m.anchorTop, nextAvailableTop);
83
83
-
resolved.push({
84
84
-
footnoteEntityID: m.footnoteEntityID,
85
85
-
blockID: m.blockID,
86
86
-
index: m.index,
87
87
-
top,
88
88
-
});
89
89
-
nextAvailableTop = top + m.height + GAP;
90
90
-
}
91
91
-
92
92
-
// Phase 3: Batch write — set positions via state update (React handles DOM writes)
93
93
-
setPositions(resolved);
94
94
-
}, [footnotes]);
95
95
-
96
96
-
useEffect(() => {
97
97
-
if (!props.visible) return;
98
98
-
calculatePositions();
99
99
-
100
100
-
let scrollWrapper = containerRef.current?.closest(".pageWrapper")
101
101
-
?.querySelector(".pageScrollWrapper") as HTMLElement | null;
102
102
-
if (!scrollWrapper) return;
103
103
-
104
104
-
let onScroll = () => {
105
105
-
setScrollOffset(scrollWrapper!.scrollTop);
106
106
-
};
107
107
-
108
108
-
scrollWrapper.addEventListener("scroll", onScroll);
109
109
-
110
110
-
let resizeObserver = new ResizeObserver(calculatePositions);
111
111
-
resizeObserver.observe(scrollWrapper);
112
112
-
113
113
-
let mutationObserver = new MutationObserver(calculatePositions);
114
114
-
mutationObserver.observe(scrollWrapper, {
115
115
-
childList: true,
116
116
-
subtree: true,
117
117
-
characterData: true,
118
118
-
});
119
119
-
120
120
-
return () => {
121
121
-
scrollWrapper!.removeEventListener("scroll", onScroll);
122
122
-
resizeObserver.disconnect();
123
123
-
mutationObserver.disconnect();
124
124
-
};
125
125
-
}, [props.visible, calculatePositions]);
24
24
+
let items: EditorFootnoteItem[] = footnotes.map((fn) => ({
25
25
+
id: fn.footnoteEntityID,
26
26
+
index: fn.index,
27
27
+
footnoteEntityID: fn.footnoteEntityID,
28
28
+
blockID: fn.blockID,
29
29
+
}));
126
30
127
127
-
if (!props.visible || footnotes.length === 0) return null;
31
31
+
let getAnchorSelector = useCallback(
32
32
+
(item: EditorFootnoteItem) =>
33
33
+
`.footnote-ref[data-footnote-id="${item.id}"]`,
34
34
+
[],
35
35
+
);
128
36
129
129
-
return (
130
130
-
<div
131
131
-
ref={containerRef}
132
132
-
className="footnote-side-column hidden lg:block absolute top-0 left-full w-[200px] ml-3 pointer-events-none"
133
133
-
style={{ height: "100%" }}
134
134
-
>
135
135
-
<div
136
136
-
ref={innerRef}
137
137
-
className="relative pointer-events-auto"
138
138
-
style={{ transform: `translateY(-${scrollOffset}px)` }}
139
139
-
>
140
140
-
{positions.map((fn) => (
141
141
-
<FootnoteSideItem
142
142
-
key={fn.footnoteEntityID}
143
143
-
footnoteEntityID={fn.footnoteEntityID}
144
144
-
top={fn.top}
145
145
-
onResize={calculatePositions}
146
146
-
>
147
147
-
<FootnoteEditor
148
148
-
footnoteEntityID={fn.footnoteEntityID}
149
149
-
index={fn.index}
150
150
-
editable={permissions.write}
151
151
-
onDelete={
152
152
-
permissions.write
153
153
-
? () => deleteFootnoteFromBlock(fn.footnoteEntityID, fn.blockID, rep.rep)
154
154
-
: undefined
155
155
-
}
156
156
-
/>
157
157
-
</FootnoteSideItem>
158
158
-
))}
159
159
-
</div>
160
160
-
</div>
37
37
+
let renderItem = useCallback(
38
38
+
(item: EditorFootnoteItem & { top: number }) => (
39
39
+
<FootnoteEditor
40
40
+
footnoteEntityID={item.footnoteEntityID}
41
41
+
index={item.index}
42
42
+
editable={permissions.write}
43
43
+
onDelete={
44
44
+
permissions.write
45
45
+
? () => deleteFootnoteFromBlock(item.footnoteEntityID, item.blockID, rep.rep)
46
46
+
: undefined
47
47
+
}
48
48
+
/>
49
49
+
),
50
50
+
[permissions.write, rep.rep],
161
51
);
162
162
-
}
163
163
-
164
164
-
function FootnoteSideItem(props: {
165
165
-
children: React.ReactNode;
166
166
-
footnoteEntityID: string;
167
167
-
top: number;
168
168
-
onResize: () => void;
169
169
-
}) {
170
170
-
let ref = useRef<HTMLDivElement>(null);
171
171
-
let [overflows, setOverflows] = useState(false);
172
172
-
173
173
-
useEffect(() => {
174
174
-
let el = ref.current;
175
175
-
if (!el) return;
176
176
-
177
177
-
let check = () => setOverflows(el!.scrollHeight > el!.clientHeight + 1);
178
178
-
check();
179
179
-
180
180
-
// Watch for content changes (text edits)
181
181
-
let mo = new MutationObserver(check);
182
182
-
mo.observe(el, { childList: true, subtree: true, characterData: true });
183
183
-
184
184
-
// Watch for size changes (expand/collapse on hover) and trigger reflow
185
185
-
let ro = new ResizeObserver(() => {
186
186
-
check();
187
187
-
props.onResize();
188
188
-
});
189
189
-
ro.observe(el);
190
190
-
191
191
-
return () => {
192
192
-
mo.disconnect();
193
193
-
ro.disconnect();
194
194
-
};
195
195
-
}, [props.onResize]);
196
52
197
53
return (
198
198
-
<div
199
199
-
ref={ref}
200
200
-
data-footnote-side-id={props.footnoteEntityID}
201
201
-
className={`absolute left-0 right-0 text-xs footnote-side-enter footnote-side-item${overflows ? " has-overflow" : ""}`}
202
202
-
style={{ top: props.top }}
203
203
-
>
204
204
-
{props.children}
205
205
-
</div>
54
54
+
<FootnoteSideColumnLayout
55
55
+
items={items}
56
56
+
visible={props.visible}
57
57
+
getAnchorSelector={getAnchorSelector}
58
58
+
renderItem={renderItem}
59
59
+
/>
206
60
);
207
61
}
+167
components/Footnotes/FootnoteSideColumnLayout.tsx
···
1
1
+
"use client";
2
2
+
3
3
+
import { useEffect, useRef, useState, useCallback, ReactNode } from "react";
4
4
+
5
5
+
export type FootnoteSideItem = {
6
6
+
id: string;
7
7
+
index: number;
8
8
+
};
9
9
+
10
10
+
const GAP = 4;
11
11
+
12
12
+
export function FootnoteSideColumnLayout<T extends FootnoteSideItem>(props: {
13
13
+
items: T[];
14
14
+
visible: boolean;
15
15
+
getAnchorSelector: (item: T) => string;
16
16
+
renderItem: (item: T & { top: number }) => ReactNode;
17
17
+
}) {
18
18
+
let containerRef = useRef<HTMLDivElement>(null);
19
19
+
let innerRef = useRef<HTMLDivElement>(null);
20
20
+
let [positions, setPositions] = useState<(T & { top: number })[]>([]);
21
21
+
let [scrollOffset, setScrollOffset] = useState(0);
22
22
+
23
23
+
let calculatePositions = useCallback(() => {
24
24
+
let container = containerRef.current;
25
25
+
let inner = innerRef.current;
26
26
+
if (!container || !inner || props.items.length === 0) {
27
27
+
setPositions([]);
28
28
+
return;
29
29
+
}
30
30
+
31
31
+
let scrollWrapper = container.closest(".pageWrapper")
32
32
+
?.querySelector(".pageScrollWrapper") as HTMLElement | null;
33
33
+
if (!scrollWrapper) return;
34
34
+
35
35
+
let scrollTop = scrollWrapper.scrollTop;
36
36
+
let scrollWrapperRect = scrollWrapper.getBoundingClientRect();
37
37
+
setScrollOffset(scrollTop);
38
38
+
39
39
+
let measurements: (T & { anchorTop: number; height: number })[] = [];
40
40
+
41
41
+
for (let item of props.items) {
42
42
+
let supEl = scrollWrapper.querySelector(
43
43
+
props.getAnchorSelector(item),
44
44
+
) as HTMLElement | null;
45
45
+
if (!supEl) continue;
46
46
+
47
47
+
let supRect = supEl.getBoundingClientRect();
48
48
+
let anchorTop = supRect.top - scrollWrapperRect.top + scrollTop;
49
49
+
50
50
+
let itemEl = inner.querySelector(
51
51
+
`[data-footnote-side-id="${item.id}"]`,
52
52
+
) as HTMLElement | null;
53
53
+
let height = itemEl ? itemEl.offsetHeight : 54;
54
54
+
55
55
+
measurements.push({ ...item, anchorTop, height });
56
56
+
}
57
57
+
58
58
+
let resolved: (T & { top: number })[] = [];
59
59
+
let nextAvailableTop = 0;
60
60
+
for (let m of measurements) {
61
61
+
let top = Math.max(m.anchorTop, nextAvailableTop);
62
62
+
resolved.push({
63
63
+
...m,
64
64
+
top,
65
65
+
});
66
66
+
nextAvailableTop = top + m.height + GAP;
67
67
+
}
68
68
+
69
69
+
setPositions(resolved);
70
70
+
}, [props.items, props.getAnchorSelector]);
71
71
+
72
72
+
useEffect(() => {
73
73
+
if (!props.visible) return;
74
74
+
calculatePositions();
75
75
+
76
76
+
let scrollWrapper = containerRef.current?.closest(".pageWrapper")
77
77
+
?.querySelector(".pageScrollWrapper") as HTMLElement | null;
78
78
+
if (!scrollWrapper) return;
79
79
+
80
80
+
let onScroll = () => {
81
81
+
setScrollOffset(scrollWrapper!.scrollTop);
82
82
+
};
83
83
+
84
84
+
scrollWrapper.addEventListener("scroll", onScroll);
85
85
+
86
86
+
let resizeObserver = new ResizeObserver(calculatePositions);
87
87
+
resizeObserver.observe(scrollWrapper);
88
88
+
89
89
+
let mutationObserver = new MutationObserver(calculatePositions);
90
90
+
mutationObserver.observe(scrollWrapper, {
91
91
+
childList: true,
92
92
+
subtree: true,
93
93
+
characterData: true,
94
94
+
});
95
95
+
96
96
+
return () => {
97
97
+
scrollWrapper!.removeEventListener("scroll", onScroll);
98
98
+
resizeObserver.disconnect();
99
99
+
mutationObserver.disconnect();
100
100
+
};
101
101
+
}, [props.visible, calculatePositions]);
102
102
+
103
103
+
if (!props.visible || props.items.length === 0) return null;
104
104
+
105
105
+
return (
106
106
+
<div
107
107
+
ref={containerRef}
108
108
+
className="footnote-side-column hidden lg:block absolute top-0 left-full w-[200px] ml-3 pointer-events-none"
109
109
+
style={{ height: "100%" }}
110
110
+
>
111
111
+
<div
112
112
+
ref={innerRef}
113
113
+
className="relative pointer-events-auto"
114
114
+
style={{ transform: `translateY(-${scrollOffset}px)` }}
115
115
+
>
116
116
+
{positions.map((item) => (
117
117
+
<SideItem key={item.id} id={item.id} top={item.top} onResize={calculatePositions}>
118
118
+
{props.renderItem(item)}
119
119
+
</SideItem>
120
120
+
))}
121
121
+
</div>
122
122
+
</div>
123
123
+
);
124
124
+
}
125
125
+
126
126
+
function SideItem(props: {
127
127
+
children: ReactNode;
128
128
+
id: string;
129
129
+
top: number;
130
130
+
onResize: () => void;
131
131
+
}) {
132
132
+
let ref = useRef<HTMLDivElement>(null);
133
133
+
let [overflows, setOverflows] = useState(false);
134
134
+
135
135
+
useEffect(() => {
136
136
+
let el = ref.current;
137
137
+
if (!el) return;
138
138
+
139
139
+
let check = () => setOverflows(el!.scrollHeight > el!.clientHeight + 1);
140
140
+
check();
141
141
+
142
142
+
let ro = new ResizeObserver(() => {
143
143
+
check();
144
144
+
props.onResize();
145
145
+
});
146
146
+
ro.observe(el);
147
147
+
148
148
+
let mo = new MutationObserver(check);
149
149
+
mo.observe(el, { childList: true, subtree: true, characterData: true });
150
150
+
151
151
+
return () => {
152
152
+
ro.disconnect();
153
153
+
mo.disconnect();
154
154
+
};
155
155
+
}, [props.onResize]);
156
156
+
157
157
+
return (
158
158
+
<div
159
159
+
ref={ref}
160
160
+
data-footnote-side-id={props.id}
161
161
+
className={`absolute left-0 right-0 text-xs footnote-side-enter footnote-side-item${overflows ? " has-overflow" : ""}`}
162
162
+
style={{ top: props.top }}
163
163
+
>
164
164
+
{props.children}
165
165
+
</div>
166
166
+
);
167
167
+
}