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
add footnotes
awarm.space
5 days ago
713d14b4
da378130
+1362
-32
31 changed files
expand all
collapse all
unified
split
actions
publishToPublication.ts
app
globals.css
lish
[did]
[publication]
[rkey]
Blocks
BaseTextBlock.tsx
TextBlock.tsx
TextBlockCore.tsx
Footnotes
FootnoteCollectorContext.tsx
PublishedFootnotes.tsx
LinearDocumentPage.tsx
PostContent.tsx
components
Blocks
TextBlock
RenderYJSFragment.tsx
inputRules.ts
insertFootnote.ts
mountProsemirror.ts
schema.ts
Footnotes
FootnoteContext.tsx
FootnoteEditor.tsx
FootnotePopover.tsx
FootnoteSection.tsx
FootnoteSideColumn.tsx
deleteFootnoteFromBlock.ts
usePageFootnotes.ts
Pages
Page.tsx
Toolbar
FootnoteButton.tsx
TextToolbar.tsx
lexicons
api
lexicons.ts
types
pub
leaflet
richtext
facet.ts
pub
leaflet
richtext
facet.json
src
replicache
attributes.ts
mutations.ts
utils
deleteBlock.ts
yjsFragmentToString.ts
+39
-2
actions/publishToPublication.ts
···
485
485
).flat();
486
486
}
487
487
async function blockToRecord(b: Block, did: string) {
488
488
+
const footnoteContentResolver = (footnoteEntityID: string) => {
489
489
+
let [content] = scan.eav(footnoteEntityID, "block/text");
490
490
+
if (!content) return { plaintext: "", facets: [] as PubLeafletRichtextFacet.Main[] };
491
491
+
let doc = new Y.Doc();
492
492
+
const update = base64.toByteArray(content.data.value);
493
493
+
Y.applyUpdate(doc, update);
494
494
+
let nodes = doc.getXmlElement("prosemirror").toArray();
495
495
+
let plaintext = YJSFragmentToString(nodes[0]);
496
496
+
let { facets } = YJSFragmentToFacets(nodes[0]);
497
497
+
return { plaintext, facets };
498
498
+
};
488
499
const getBlockContent = (b: string) => {
489
500
let [content] = scan.eav(b, "block/text");
490
501
if (!content) return ["", [] as PubLeafletRichtextFacet.Main[]] as const;
···
493
504
Y.applyUpdate(doc, update);
494
505
let nodes = doc.getXmlElement("prosemirror").toArray();
495
506
let stringValue = YJSFragmentToString(nodes[0]);
496
496
-
let { facets } = YJSFragmentToFacets(nodes[0]);
507
507
+
let { facets } = YJSFragmentToFacets(nodes[0], 0, footnoteContentResolver);
497
508
return [stringValue, facets] as const;
498
509
};
499
510
if (b.type === "card") {
···
759
770
function YJSFragmentToFacets(
760
771
node: Y.XmlElement | Y.XmlText | Y.XmlHook,
761
772
byteOffset: number = 0,
773
773
+
footnoteContentResolver?: (footnoteEntityID: string) => { plaintext: string; facets: PubLeafletRichtextFacet.Main[] },
762
774
): { facets: PubLeafletRichtextFacet.Main[]; byteLength: number } {
763
775
if (node.constructor === Y.XmlElement) {
776
776
+
// Handle footnote inline nodes
777
777
+
if (node.nodeName === "footnote") {
778
778
+
const footnoteEntityID = node.getAttribute("footnoteEntityID") || "";
779
779
+
const placeholder = "*";
780
780
+
const unicodestring = new UnicodeString(placeholder);
781
781
+
let footnoteContent = footnoteContentResolver?.(footnoteEntityID);
782
782
+
const facet: PubLeafletRichtextFacet.Main = {
783
783
+
index: {
784
784
+
byteStart: byteOffset,
785
785
+
byteEnd: byteOffset + unicodestring.length,
786
786
+
},
787
787
+
features: [
788
788
+
{
789
789
+
$type: "pub.leaflet.richtext.facet#footnote",
790
790
+
footnoteId: footnoteEntityID,
791
791
+
contentPlaintext: footnoteContent?.plaintext || "",
792
792
+
...(footnoteContent?.facets?.length
793
793
+
? { contentFacets: footnoteContent.facets }
794
794
+
: {}),
795
795
+
},
796
796
+
],
797
797
+
};
798
798
+
return { facets: [facet], byteLength: unicodestring.length };
799
799
+
}
800
800
+
764
801
// Handle inline mention nodes
765
802
if (node.nodeName === "didMention") {
766
803
const text = node.getAttribute("text") || "";
···
807
844
let allFacets: PubLeafletRichtextFacet.Main[] = [];
808
845
let currentOffset = byteOffset;
809
846
for (const child of node.toArray()) {
810
810
-
const result = YJSFragmentToFacets(child, currentOffset);
847
847
+
const result = YJSFragmentToFacets(child, currentOffset, footnoteContentResolver);
811
848
allFacets.push(...result.facets);
812
849
currentOffset += result.byteLength;
813
850
}
+62
app/globals.css
···
489
489
animation-iteration-count: 1;
490
490
animation-timing-function: ease-in;
491
491
}
492
492
+
493
493
+
.postPageContent {
494
494
+
counter-reset: footnote;
495
495
+
}
496
496
+
.footnote-ref {
497
497
+
counter-increment: footnote;
498
498
+
cursor: pointer;
499
499
+
color: rgb(var(--accent-contrast));
500
500
+
opacity: 0.7;
501
501
+
}
502
502
+
.footnote-ref::after {
503
503
+
content: counter(footnote);
504
504
+
vertical-align: super;
505
505
+
font-size: 75%;
506
506
+
}
507
507
+
.footnote-ref ~ br.ProseMirror-trailingBreak {
508
508
+
display: inline;
509
509
+
width: 4px;
510
510
+
}
511
511
+
.footnote-ref ~ img.ProseMirror-separator {
512
512
+
display: none;
513
513
+
}
514
514
+
515
515
+
.footnote-side-enter {
516
516
+
animation: footnote-fade-in 200ms ease-out;
517
517
+
}
518
518
+
@keyframes footnote-fade-in {
519
519
+
from {
520
520
+
opacity: 0;
521
521
+
transform: translateX(-8px);
522
522
+
}
523
523
+
to {
524
524
+
opacity: 1;
525
525
+
transform: translateX(0);
526
526
+
}
527
527
+
}
528
528
+
529
529
+
.footnote-side-item {
530
530
+
max-height: 4.5em;
531
531
+
overflow: hidden;
532
532
+
transition: max-height 200ms ease;
533
533
+
}
534
534
+
.footnote-side-item.has-overflow::after {
535
535
+
content: "";
536
536
+
position: absolute;
537
537
+
bottom: 0;
538
538
+
left: 0;
539
539
+
right: 0;
540
540
+
height: 1.5em;
541
541
+
background: linear-gradient(to bottom, transparent, rgb(var(--bg-page)));
542
542
+
pointer-events: none;
543
543
+
opacity: 1;
544
544
+
transition: opacity 200ms ease;
545
545
+
}
546
546
+
.footnote-side-item:hover,
547
547
+
.footnote-side-item:focus-within {
548
548
+
max-height: 40em;
549
549
+
}
550
550
+
.footnote-side-item:hover::after,
551
551
+
.footnote-side-item:focus-within::after {
552
552
+
opacity: 0;
553
553
+
}
+1
app/lish/[did]/[publication]/[rkey]/Blocks/BaseTextBlock.tsx
···
20
20
renderers={{
21
21
DidMention: DidMentionWithPopover,
22
22
}}
23
23
+
footnoteIndexMap={props.footnoteIndexMap}
23
24
/>
24
25
);
25
26
}
+2
-1
app/lish/[did]/[publication]/[rkey]/Blocks/TextBlock.tsx
···
12
12
index: number[];
13
13
preview?: boolean;
14
14
pageId?: string;
15
15
+
footnoteIndexMap?: Map<string, number>;
15
16
}) {
16
17
let children = [];
17
18
let highlights = useHighlight(props.index, props.pageId);
···
48
49
}
49
50
return facets;
50
51
}, [props.plaintext, props.facets, highlights, props.preview, props.pageId]);
51
51
-
return <BaseTextBlock {...props} facets={facets} />;
52
52
+
return <BaseTextBlock {...props} facets={facets} footnoteIndexMap={props.footnoteIndexMap} />;
52
53
}
53
54
54
55
function addFacet(facets: Facet[], newFacet: Facet, length: number) {
+24
app/lish/[did]/[publication]/[rkey]/Blocks/TextBlockCore.tsx
···
7
7
8
8
export type FacetRenderers = {
9
9
DidMention?: (props: { did: string; children: ReactNode }) => ReactNode;
10
10
+
FootnoteRef?: (props: { footnoteId: string; index: number; children: ReactNode }) => ReactNode;
10
11
};
11
12
12
13
export type TextBlockCoreProps = {
···
15
16
index: number[];
16
17
preview?: boolean;
17
18
renderers?: FacetRenderers;
19
19
+
footnoteIndexMap?: Map<string, number>;
18
20
};
19
21
20
22
export function TextBlockCore(props: TextBlockCoreProps) {
···
38
40
let isAtMention = segment.facet?.find(PubLeafletRichtextFacet.isAtMention);
39
41
let isUnderline = segment.facet?.find(PubLeafletRichtextFacet.isUnderline);
40
42
let isItalic = segment.facet?.find(PubLeafletRichtextFacet.isItalic);
43
43
+
let isFootnote = segment.facet?.find(PubLeafletRichtextFacet.isFootnote);
41
44
let isHighlighted = segment.facet?.find(
42
45
PubLeafletRichtextFacet.isHighlight,
43
46
);
47
47
+
48
48
+
if (isFootnote) {
49
49
+
let fnIndex = props.footnoteIndexMap?.get(isFootnote.footnoteId) ?? 0;
50
50
+
const FootnoteRenderer = props.renderers?.FootnoteRef;
51
51
+
if (FootnoteRenderer) {
52
52
+
children.push(
53
53
+
<FootnoteRenderer key={counter} footnoteId={isFootnote.footnoteId} index={fnIndex}>
54
54
+
<sup className="text-accent-contrast cursor-pointer">{fnIndex}</sup>
55
55
+
</FootnoteRenderer>,
56
56
+
);
57
57
+
} else {
58
58
+
children.push(
59
59
+
<sup key={counter} className="text-accent-contrast cursor-pointer text-[0.75em]" id={`fnref-${isFootnote.footnoteId}`}>
60
60
+
<a href={`#fn-${isFootnote.footnoteId}`} className="no-underline hover:underline">{fnIndex}</a>
61
61
+
</sup>,
62
62
+
);
63
63
+
}
64
64
+
counter++;
65
65
+
continue;
66
66
+
}
67
67
+
44
68
let className = `
45
69
${isCode ? "inline-code" : ""}
46
70
${id ? "scroll-mt-12 scroll-mb-10" : ""}
+56
app/lish/[did]/[publication]/[rkey]/Footnotes/FootnoteCollectorContext.tsx
···
1
1
+
"use client";
2
2
+
3
3
+
import { createContext, useContext, useRef, useCallback, useMemo } from "react";
4
4
+
import { PubLeafletRichtextFacet } from "lexicons/api";
5
5
+
6
6
+
export type CollectedFootnote = {
7
7
+
footnoteId: string;
8
8
+
index: number;
9
9
+
contentPlaintext: string;
10
10
+
contentFacets?: PubLeafletRichtextFacet.Main[];
11
11
+
};
12
12
+
13
13
+
type FootnoteCollectorContextValue = {
14
14
+
registerFootnote: (footnote: Omit<CollectedFootnote, "index">) => number;
15
15
+
getFootnotes: () => CollectedFootnote[];
16
16
+
};
17
17
+
18
18
+
const FootnoteCollectorContext = createContext<FootnoteCollectorContextValue>({
19
19
+
registerFootnote: () => 0,
20
20
+
getFootnotes: () => [],
21
21
+
});
22
22
+
23
23
+
export function useFootnoteCollector() {
24
24
+
return useContext(FootnoteCollectorContext);
25
25
+
}
26
26
+
27
27
+
export function FootnoteCollectorProvider(props: { children: React.ReactNode }) {
28
28
+
let footnotesRef = useRef<CollectedFootnote[]>([]);
29
29
+
let counterRef = useRef(1);
30
30
+
31
31
+
let registerFootnote = useCallback(
32
32
+
(footnote: Omit<CollectedFootnote, "index">) => {
33
33
+
let existing = footnotesRef.current.find(
34
34
+
(f) => f.footnoteId === footnote.footnoteId,
35
35
+
);
36
36
+
if (existing) return existing.index;
37
37
+
let index = counterRef.current++;
38
38
+
footnotesRef.current.push({ ...footnote, index });
39
39
+
return index;
40
40
+
},
41
41
+
[],
42
42
+
);
43
43
+
44
44
+
let getFootnotes = useCallback(() => footnotesRef.current, []);
45
45
+
46
46
+
let value = useMemo(
47
47
+
() => ({ registerFootnote, getFootnotes }),
48
48
+
[registerFootnote, getFootnotes],
49
49
+
);
50
50
+
51
51
+
return (
52
52
+
<FootnoteCollectorContext.Provider value={value}>
53
53
+
{props.children}
54
54
+
</FootnoteCollectorContext.Provider>
55
55
+
);
56
56
+
}
+116
app/lish/[did]/[publication]/[rkey]/Footnotes/PublishedFootnotes.tsx
···
1
1
+
"use client";
2
2
+
3
3
+
import {
4
4
+
PubLeafletRichtextFacet,
5
5
+
PubLeafletBlocksText,
6
6
+
PubLeafletBlocksHeader,
7
7
+
PubLeafletBlocksBlockquote,
8
8
+
PubLeafletPagesLinearDocument,
9
9
+
} from "lexicons/api";
10
10
+
import { TextBlockCore } from "../Blocks/TextBlockCore";
11
11
+
12
12
+
export type PublishedFootnote = {
13
13
+
footnoteId: string;
14
14
+
index: number;
15
15
+
contentPlaintext: string;
16
16
+
contentFacets?: PubLeafletRichtextFacet.Main[];
17
17
+
};
18
18
+
19
19
+
export function collectFootnotesFromBlocks(
20
20
+
blocks: PubLeafletPagesLinearDocument.Block[],
21
21
+
): PublishedFootnote[] {
22
22
+
let footnotes: PublishedFootnote[] = [];
23
23
+
let seen = new Set<string>();
24
24
+
let idx = 1;
25
25
+
26
26
+
function scanFacets(facets?: PubLeafletRichtextFacet.Main[]) {
27
27
+
if (!facets) return;
28
28
+
for (let facet of facets) {
29
29
+
for (let feature of facet.features) {
30
30
+
if (PubLeafletRichtextFacet.isFootnote(feature)) {
31
31
+
if (!seen.has(feature.footnoteId)) {
32
32
+
seen.add(feature.footnoteId);
33
33
+
footnotes.push({
34
34
+
footnoteId: feature.footnoteId,
35
35
+
index: idx++,
36
36
+
contentPlaintext: feature.contentPlaintext,
37
37
+
contentFacets: feature.contentFacets,
38
38
+
});
39
39
+
}
40
40
+
}
41
41
+
}
42
42
+
}
43
43
+
}
44
44
+
45
45
+
for (let b of blocks) {
46
46
+
let block = b.block;
47
47
+
let facets: PubLeafletRichtextFacet.Main[] | undefined;
48
48
+
if (PubLeafletBlocksText.isMain(block)) {
49
49
+
facets = block.facets;
50
50
+
} else if (PubLeafletBlocksHeader.isMain(block)) {
51
51
+
facets = block.facets;
52
52
+
} else if (PubLeafletBlocksBlockquote.isMain(block)) {
53
53
+
facets = block.facets;
54
54
+
}
55
55
+
if (facets) scanFacets(facets);
56
56
+
}
57
57
+
58
58
+
return footnotes;
59
59
+
}
60
60
+
61
61
+
export function buildFootnoteIndexMap(
62
62
+
footnotes: PublishedFootnote[],
63
63
+
): Map<string, number> {
64
64
+
let map = new Map<string, number>();
65
65
+
for (let fn of footnotes) {
66
66
+
map.set(fn.footnoteId, fn.index);
67
67
+
}
68
68
+
return map;
69
69
+
}
70
70
+
71
71
+
export function PublishedFootnoteSection(props: {
72
72
+
footnotes: PublishedFootnote[];
73
73
+
}) {
74
74
+
if (props.footnotes.length === 0) return null;
75
75
+
76
76
+
return (
77
77
+
<div className="footnote-section px-3 sm:px-4 pb-2 mt-4">
78
78
+
<hr className="border-border-light mb-3" />
79
79
+
<div className="flex flex-col gap-2">
80
80
+
{props.footnotes.map((fn) => (
81
81
+
<div
82
82
+
key={fn.footnoteId}
83
83
+
id={`fn-${fn.footnoteId}`}
84
84
+
className="flex items-start gap-2 text-xs"
85
85
+
>
86
86
+
<a
87
87
+
href={`#fnref-${fn.footnoteId}`}
88
88
+
className="text-accent-contrast font-medium shrink-0 mt-0.5 text-xs no-underline hover:underline"
89
89
+
>
90
90
+
{fn.index}.
91
91
+
</a>
92
92
+
<div className="text-secondary min-w-0">
93
93
+
{fn.contentPlaintext ? (
94
94
+
<TextBlockCore
95
95
+
plaintext={fn.contentPlaintext}
96
96
+
facets={fn.contentFacets}
97
97
+
index={[]}
98
98
+
/>
99
99
+
) : (
100
100
+
<span className="italic text-tertiary">Empty footnote</span>
101
101
+
)}
102
102
+
</div>
103
103
+
<a
104
104
+
href={`#fnref-${fn.footnoteId}`}
105
105
+
className="text-accent-contrast shrink-0 mt-0.5 text-xs no-underline hover:underline"
106
106
+
title="Back to text"
107
107
+
aria-label={`Back to footnote ${fn.index} in text`}
108
108
+
>
109
109
+
↩
110
110
+
</a>
111
111
+
</div>
112
112
+
))}
113
113
+
</div>
114
114
+
</div>
115
115
+
);
116
116
+
}
+9
app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx
···
22
22
import { SharedPageProps } from "./PostPages";
23
23
import { PostPrevNextButtons } from "./PostPrevNextButtons";
24
24
import { PostSubscribe } from "./PostSubscribe";
25
25
+
import {
26
26
+
collectFootnotesFromBlocks,
27
27
+
buildFootnoteIndexMap,
28
28
+
PublishedFootnoteSection,
29
29
+
} from "./Footnotes/PublishedFootnotes";
25
30
26
31
export function LinearDocumentPage({
27
32
blocks,
···
47
52
} = props;
48
53
let drawer = useDrawerOpen(document_uri);
49
54
const { pages } = useLeafletContent();
55
55
+
const footnotes = collectFootnotesFromBlocks(blocks);
56
56
+
const footnoteIndexMap = buildFootnoteIndexMap(footnotes);
50
57
51
58
if (!document) return null;
52
59
···
78
85
blocks={blocks}
79
86
did={did}
80
87
prerenderedCodeBlocks={prerenderedCodeBlocks}
88
88
+
footnoteIndexMap={footnoteIndexMap}
81
89
/>
90
90
+
<PublishedFootnoteSection footnotes={footnotes} />
82
91
<PostSubscribe />
83
92
<PostPrevNextButtons
84
93
showPrevNext={preferences.showPrevNext !== false && !isSubpage}
+11
app/lish/[did]/[publication]/[rkey]/PostContent.tsx
···
45
45
pageId,
46
46
pages,
47
47
pollData,
48
48
+
footnoteIndexMap,
48
49
}: {
49
50
blocks: PubLeafletPagesLinearDocument.Block[];
50
51
pageId?: string;
···
55
56
bskyPostData: AppBskyFeedDefs.PostView[];
56
57
pollData: PollData[];
57
58
pages: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[];
59
59
+
footnoteIndexMap?: Map<string, number>;
58
60
}) {
59
61
return (
60
62
<div
···
75
77
preview={preview}
76
78
prerenderedCodeBlocks={prerenderedCodeBlocks}
77
79
pollData={pollData}
80
80
+
footnoteIndexMap={footnoteIndexMap}
78
81
/>
79
82
);
80
83
})}
···
94
97
pageId,
95
98
pages,
96
99
pollData,
100
100
+
footnoteIndexMap,
97
101
}: {
98
102
pageId?: string;
99
103
preview?: boolean;
···
106
110
prerenderedCodeBlocks?: Map<string, string>;
107
111
bskyPostData: AppBskyFeedDefs.PostView[];
108
112
pollData: PollData[];
113
113
+
footnoteIndexMap?: Map<string, number>;
109
114
}) => {
110
115
let b = block;
111
116
let blockProps = {
···
361
366
index={index}
362
367
preview={preview}
363
368
pageId={pageId}
369
369
+
footnoteIndexMap={footnoteIndexMap}
364
370
/>
365
371
</blockquote>
366
372
);
···
377
383
index={index}
378
384
preview={preview}
379
385
pageId={pageId}
386
386
+
footnoteIndexMap={footnoteIndexMap}
380
387
/>
381
388
</p>
382
389
);
···
390
397
index={index}
391
398
preview={preview}
392
399
pageId={pageId}
400
400
+
footnoteIndexMap={footnoteIndexMap}
393
401
/>
394
402
</h2>
395
403
);
···
401
409
index={index}
402
410
preview={preview}
403
411
pageId={pageId}
412
412
+
footnoteIndexMap={footnoteIndexMap}
404
413
/>
405
414
</h3>
406
415
);
···
412
421
index={index}
413
422
preview={preview}
414
423
pageId={pageId}
424
424
+
footnoteIndexMap={footnoteIndexMap}
415
425
/>
416
426
</h4>
417
427
);
···
424
434
index={index}
425
435
preview={preview}
426
436
pageId={pageId}
437
437
+
footnoteIndexMap={footnoteIndexMap}
427
438
/>
428
439
</h6>
429
440
);
+15
components/Blocks/TextBlock/RenderYJSFragment.tsx
···
91
91
);
92
92
}
93
93
94
94
+
// Handle footnote inline nodes
95
95
+
if (
96
96
+
node.constructor === XmlElement &&
97
97
+
node.nodeName === "footnote"
98
98
+
) {
99
99
+
const id = node.getAttribute("footnoteEntityID") || "";
100
100
+
return (
101
101
+
<span
102
102
+
key={index}
103
103
+
className="footnote-ref"
104
104
+
data-footnote-id={id}
105
105
+
/>
106
106
+
);
107
107
+
}
108
108
+
94
109
// Handle atMention inline nodes
95
110
if (
96
111
node.constructor === XmlElement &&
+18
components/Blocks/TextBlock/inputRules.ts
···
12
12
import { useUIState } from "src/useUIState";
13
13
import { flushSync } from "react-dom";
14
14
import { LAST_USED_CODE_LANGUAGE_KEY } from "src/utils/codeLanguageStorage";
15
15
+
import { insertFootnote } from "./insertFootnote";
16
16
+
import { useEditorStates } from "src/state/useEditorState";
15
17
export const inputrules = (
16
18
propsRef: MutableRefObject<BlockProps & { entity_set: { set: string } }>,
17
19
repRef: MutableRefObject<Replicache<ReplicacheMutators> | null>,
···
224
226
attribute: "block/heading-level",
225
227
data: { type: "number", value: headingLevel },
226
228
});
229
229
+
return tr;
230
230
+
}),
231
231
+
232
232
+
// Footnote - [^ triggers footnote insertion
233
233
+
new InputRule(/\[\^$/, (state, match, start, end) => {
234
234
+
let tr = state.tr.delete(start, end);
235
235
+
setTimeout(() => {
236
236
+
let view = useEditorStates.getState().editorStates[propsRef.current.entityID]?.view;
237
237
+
if (!view || !repRef.current) return;
238
238
+
insertFootnote(
239
239
+
view,
240
240
+
propsRef.current.entityID,
241
241
+
repRef.current,
242
242
+
propsRef.current.entity_set.set,
243
243
+
);
244
244
+
}, 0);
227
245
return tr;
228
246
}),
229
247
+43
components/Blocks/TextBlock/insertFootnote.ts
···
1
1
+
import { EditorView } from "prosemirror-view";
2
2
+
import { v7 } from "uuid";
3
3
+
import { Replicache } from "replicache";
4
4
+
import type { ReplicacheMutators } from "src/replicache";
5
5
+
import { schema } from "./schema";
6
6
+
import { generateKeyBetween } from "fractional-indexing";
7
7
+
import { scanIndex } from "src/replicache/utils";
8
8
+
9
9
+
export async function insertFootnote(
10
10
+
view: EditorView,
11
11
+
blockID: string,
12
12
+
rep: Replicache<ReplicacheMutators>,
13
13
+
permissionSet: string,
14
14
+
) {
15
15
+
let footnoteEntityID = v7();
16
16
+
17
17
+
let existingFootnotes = await rep.query(async (tx) => {
18
18
+
let scan = scanIndex(tx);
19
19
+
return scan.eav(blockID, "block/footnote");
20
20
+
});
21
21
+
let lastPosition =
22
22
+
existingFootnotes.length > 0
23
23
+
? existingFootnotes
24
24
+
.map((f) => f.data.position)
25
25
+
.sort()
26
26
+
.at(-1)!
27
27
+
: null;
28
28
+
let position = generateKeyBetween(lastPosition, null);
29
29
+
30
30
+
await rep.mutate.createFootnote({
31
31
+
footnoteEntityID,
32
32
+
blockID,
33
33
+
permission_set: permissionSet,
34
34
+
position,
35
35
+
});
36
36
+
37
37
+
let node = schema.nodes.footnote.create({ footnoteEntityID });
38
38
+
let { from } = view.state.selection;
39
39
+
let tr = view.state.tr.insert(from, node);
40
40
+
view.dispatch(tr);
41
41
+
42
42
+
return footnoteEntityID;
43
43
+
}
+64
components/Blocks/TextBlock/mountProsemirror.ts
···
24
24
import { BlockProps } from "../Block";
25
25
import { useEntitySetContext } from "components/EntitySetProvider";
26
26
import { didToBlueskyUrl, atUriToUrl } from "src/utils/mentionUtils";
27
27
+
import { useFootnotePopoverStore } from "components/Footnotes/FootnotePopover";
27
28
28
29
export function useMountProsemirror({
29
30
props,
···
81
82
handleClickOn: (_view, _pos, node, _nodePos, _event, direct) => {
82
83
if (!direct) return;
83
84
85
85
+
// Check for footnote inline nodes
86
86
+
if (node?.type === schema.nodes.footnote) {
87
87
+
let footnoteID = node.attrs.footnoteEntityID;
88
88
+
let supEl = _event.target as HTMLElement;
89
89
+
let sup = supEl.closest(".footnote-ref") as HTMLElement | null;
90
90
+
if (!sup) return;
91
91
+
92
92
+
// On mobile/tablet, show popover
93
93
+
let isDesktop = window.matchMedia("(min-width: 1024px)").matches;
94
94
+
if (!isDesktop) {
95
95
+
let store = useFootnotePopoverStore.getState();
96
96
+
if (store.activeFootnoteID === footnoteID) {
97
97
+
store.close();
98
98
+
} else {
99
99
+
store.open(footnoteID, sup);
100
100
+
}
101
101
+
return;
102
102
+
}
103
103
+
104
104
+
// On desktop, prefer the side column editor if visible
105
105
+
let sideColumn = document.querySelector(".footnote-side-column");
106
106
+
let editor = sideColumn?.querySelector(
107
107
+
`[data-footnote-editor="${footnoteID}"]`,
108
108
+
) as HTMLElement | null;
109
109
+
// Fall back to the bottom section
110
110
+
if (!editor) {
111
111
+
editor = document.querySelector(
112
112
+
`[data-footnote-editor="${footnoteID}"]`,
113
113
+
) as HTMLElement | null;
114
114
+
}
115
115
+
if (editor) {
116
116
+
editor.scrollIntoView({ behavior: "smooth", block: "nearest" });
117
117
+
let pm = editor.querySelector(".ProseMirror") as HTMLElement | null;
118
118
+
if (pm) {
119
119
+
setTimeout(() => pm!.focus(), 100);
120
120
+
}
121
121
+
}
122
122
+
return;
123
123
+
}
124
124
+
84
125
// Check for didMention inline nodes
85
126
if (node?.type === schema.nodes.didMention) {
86
127
window.open(
···
146
187
let addToHistory = tr.getMeta("addToHistory");
147
188
let isBulkOp = tr.getMeta("bulkOp");
148
189
let docHasChanges = tr.steps.length !== 0 || tr.docChanged;
190
190
+
191
191
+
// Diff for removed/added footnote nodes
192
192
+
if (docHasChanges) {
193
193
+
let oldFootnotes = new Set<string>();
194
194
+
let newFootnotes = new Set<string>();
195
195
+
oldEditorState.doc.descendants((n) => {
196
196
+
if (n.type.name === "footnote")
197
197
+
oldFootnotes.add(n.attrs.footnoteEntityID);
198
198
+
});
199
199
+
newState.doc.descendants((n) => {
200
200
+
if (n.type.name === "footnote")
201
201
+
newFootnotes.add(n.attrs.footnoteEntityID);
202
202
+
});
203
203
+
// Removed footnotes
204
204
+
for (let id of oldFootnotes) {
205
205
+
if (!newFootnotes.has(id)) {
206
206
+
repRef.current?.mutate.deleteFootnote({
207
207
+
footnoteEntityID: id,
208
208
+
blockID: entityID,
209
209
+
});
210
210
+
}
211
211
+
}
212
212
+
}
149
213
150
214
// Handle undo/redo history with timeout-based grouping
151
215
if (addToHistory !== false && docHasChanges) {
+35
components/Blocks/TextBlock/schema.ts
···
194
194
];
195
195
},
196
196
} as NodeSpec,
197
197
+
footnote: {
198
198
+
attrs: { footnoteEntityID: {} },
199
199
+
group: "inline",
200
200
+
inline: true,
201
201
+
atom: true,
202
202
+
selectable: false,
203
203
+
draggable: false,
204
204
+
parseDOM: [
205
205
+
{
206
206
+
tag: "span.footnote-ref",
207
207
+
getAttrs(dom: HTMLElement) {
208
208
+
return {
209
209
+
footnoteEntityID: dom.getAttribute("data-footnote-id"),
210
210
+
};
211
211
+
},
212
212
+
},
213
213
+
{
214
214
+
tag: "sup.footnote-ref",
215
215
+
getAttrs(dom: HTMLElement) {
216
216
+
return {
217
217
+
footnoteEntityID: dom.getAttribute("data-footnote-id"),
218
218
+
};
219
219
+
},
220
220
+
},
221
221
+
],
222
222
+
toDOM(node) {
223
223
+
return [
224
224
+
"span",
225
225
+
{
226
226
+
class: "footnote-ref",
227
227
+
"data-footnote-id": node.attrs.footnoteEntityID,
228
228
+
},
229
229
+
];
230
230
+
},
231
231
+
} as NodeSpec,
197
232
didMention: {
198
233
attrs: {
199
234
did: {},
+16
components/Footnotes/FootnoteContext.tsx
···
1
1
+
import { createContext, useContext } from "react";
2
2
+
import type { FootnoteInfo } from "./usePageFootnotes";
3
3
+
4
4
+
type FootnoteContextValue = {
5
5
+
footnotes: FootnoteInfo[];
6
6
+
indexMap: Record<string, number>;
7
7
+
};
8
8
+
9
9
+
export const FootnoteContext = createContext<FootnoteContextValue>({
10
10
+
footnotes: [],
11
11
+
indexMap: {},
12
12
+
});
13
13
+
14
14
+
export function useFootnoteContext() {
15
15
+
return useContext(FootnoteContext);
16
16
+
}
+156
components/Footnotes/FootnoteEditor.tsx
···
1
1
+
import { useLayoutEffect, useRef, useState, useEffect } from "react";
2
2
+
import { EditorState } from "prosemirror-state";
3
3
+
import { EditorView } from "prosemirror-view";
4
4
+
import { baseKeymap, toggleMark } from "prosemirror-commands";
5
5
+
import { keymap } from "prosemirror-keymap";
6
6
+
import { ySyncPlugin } from "y-prosemirror";
7
7
+
import * as Y from "yjs";
8
8
+
import * as base64 from "base64-js";
9
9
+
import { schema } from "components/Blocks/TextBlock/schema";
10
10
+
import { useEntity, useReplicache } from "src/replicache";
11
11
+
import { autolink } from "components/Blocks/TextBlock/autolink-plugin";
12
12
+
import { CloseTiny } from "components/Icons/CloseTiny";
13
13
+
14
14
+
export function FootnoteEditor(props: {
15
15
+
footnoteEntityID: string;
16
16
+
index: number;
17
17
+
editable: boolean;
18
18
+
onDelete?: () => void;
19
19
+
autoFocus?: boolean;
20
20
+
}) {
21
21
+
let mountRef = useRef<HTMLDivElement | null>(null);
22
22
+
let rep = useReplicache();
23
23
+
let value = useFootnoteYJS(props.footnoteEntityID);
24
24
+
25
25
+
useLayoutEffect(() => {
26
26
+
if (!mountRef.current || !value) return;
27
27
+
28
28
+
let plugins = [
29
29
+
ySyncPlugin(value),
30
30
+
keymap({
31
31
+
"Meta-b": toggleMark(schema.marks.strong),
32
32
+
"Ctrl-b": toggleMark(schema.marks.strong),
33
33
+
"Meta-u": toggleMark(schema.marks.underline),
34
34
+
"Ctrl-u": toggleMark(schema.marks.underline),
35
35
+
"Meta-i": toggleMark(schema.marks.em),
36
36
+
"Ctrl-i": toggleMark(schema.marks.em),
37
37
+
"Shift-Enter": (state, dispatch) => {
38
38
+
let hardBreak = schema.nodes.hard_break.create();
39
39
+
if (dispatch) {
40
40
+
dispatch(
41
41
+
state.tr.replaceSelectionWith(hardBreak).scrollIntoView(),
42
42
+
);
43
43
+
}
44
44
+
return true;
45
45
+
},
46
46
+
Enter: (_state, _dispatch, view) => {
47
47
+
view?.dom.blur();
48
48
+
return true;
49
49
+
},
50
50
+
}),
51
51
+
keymap(baseKeymap),
52
52
+
autolink({
53
53
+
type: schema.marks.link,
54
54
+
shouldAutoLink: () => true,
55
55
+
defaultProtocol: "https",
56
56
+
}),
57
57
+
];
58
58
+
59
59
+
let state = EditorState.create({ schema, plugins });
60
60
+
let view = new EditorView(
61
61
+
{ mount: mountRef.current },
62
62
+
{
63
63
+
state,
64
64
+
editable: () => props.editable,
65
65
+
dispatchTransaction(this: EditorView, tr) {
66
66
+
let newState = this.state.apply(tr);
67
67
+
this.updateState(newState);
68
68
+
},
69
69
+
},
70
70
+
);
71
71
+
72
72
+
if (props.autoFocus) {
73
73
+
setTimeout(() => view.focus(), 50);
74
74
+
}
75
75
+
76
76
+
return () => {
77
77
+
view.destroy();
78
78
+
};
79
79
+
}, [props.footnoteEntityID, value, props.editable, props.autoFocus]);
80
80
+
81
81
+
return (
82
82
+
<div className="footnote-editor flex items-start gap-2 text-xs group/footnote" data-footnote-editor={props.footnoteEntityID}>
83
83
+
<button
84
84
+
className="text-accent-contrast font-medium shrink-0 text-xs leading-normal hover:underline cursor-pointer"
85
85
+
onClick={() => {
86
86
+
let ref = document.querySelector(
87
87
+
`.footnote-ref[data-footnote-id="${props.footnoteEntityID}"]`,
88
88
+
);
89
89
+
if (ref) {
90
90
+
ref.scrollIntoView({ behavior: "smooth", block: "center" });
91
91
+
}
92
92
+
}}
93
93
+
title="Jump to footnote in text"
94
94
+
>
95
95
+
{props.index}.
96
96
+
</button>
97
97
+
<div
98
98
+
ref={mountRef}
99
99
+
className="grow outline-hidden min-w-0 text-secondary [&_.ProseMirror]:outline-hidden"
100
100
+
style={{ wordBreak: "break-word" }}
101
101
+
/>
102
102
+
{props.editable && props.onDelete && (
103
103
+
<button
104
104
+
className="shrink-0 mt-0.5 text-tertiary hover:text-primary opacity-0 group-hover/footnote:opacity-100 transition-opacity"
105
105
+
onClick={props.onDelete}
106
106
+
title="Delete footnote"
107
107
+
>
108
108
+
<CloseTiny />
109
109
+
</button>
110
110
+
)}
111
111
+
</div>
112
112
+
);
113
113
+
}
114
114
+
115
115
+
function useFootnoteYJS(footnoteEntityID: string) {
116
116
+
const [ydoc] = useState(new Y.Doc());
117
117
+
const docState = useEntity(footnoteEntityID, "block/text");
118
118
+
let rep = useReplicache();
119
119
+
const [yText] = useState(ydoc.getXmlFragment("prosemirror"));
120
120
+
121
121
+
if (docState) {
122
122
+
const update = base64.toByteArray(docState.data.value);
123
123
+
Y.applyUpdate(ydoc, update);
124
124
+
}
125
125
+
126
126
+
useEffect(() => {
127
127
+
if (!rep.rep) return;
128
128
+
let timeout = null as null | number;
129
129
+
const updateReplicache = async () => {
130
130
+
const update = Y.encodeStateAsUpdate(ydoc);
131
131
+
await rep.rep?.mutate.assertFact({
132
132
+
ignoreUndo: true,
133
133
+
entity: footnoteEntityID,
134
134
+
attribute: "block/text",
135
135
+
data: {
136
136
+
value: base64.fromByteArray(update),
137
137
+
type: "text",
138
138
+
},
139
139
+
});
140
140
+
};
141
141
+
const f = async (_events: Y.YEvent<any>[], transaction: Y.Transaction) => {
142
142
+
if (!transaction.origin) return;
143
143
+
if (timeout) clearTimeout(timeout);
144
144
+
timeout = window.setTimeout(async () => {
145
145
+
updateReplicache();
146
146
+
}, 300);
147
147
+
};
148
148
+
149
149
+
yText.observeDeep(f);
150
150
+
return () => {
151
151
+
yText.unobserveDeep(f);
152
152
+
};
153
153
+
}, [yText, footnoteEntityID, rep, ydoc]);
154
154
+
155
155
+
return yText;
156
156
+
}
+130
components/Footnotes/FootnotePopover.tsx
···
1
1
+
"use client";
2
2
+
3
3
+
import { useEffect, useRef, useState, useCallback } from "react";
4
4
+
import { create } from "zustand";
5
5
+
import { useFootnoteContext } from "./FootnoteContext";
6
6
+
import { FootnoteEditor } from "./FootnoteEditor";
7
7
+
import { useReplicache } from "src/replicache";
8
8
+
import { useEntitySetContext } from "components/EntitySetProvider";
9
9
+
import { deleteFootnoteFromBlock } from "./deleteFootnoteFromBlock";
10
10
+
11
11
+
type FootnotePopoverState = {
12
12
+
activeFootnoteID: string | null;
13
13
+
anchorElement: HTMLElement | null;
14
14
+
open: (footnoteID: string, anchor: HTMLElement) => void;
15
15
+
close: () => void;
16
16
+
};
17
17
+
18
18
+
export const useFootnotePopoverStore = create<FootnotePopoverState>((set) => ({
19
19
+
activeFootnoteID: null,
20
20
+
anchorElement: null,
21
21
+
open: (footnoteID, anchor) =>
22
22
+
set({ activeFootnoteID: footnoteID, anchorElement: anchor }),
23
23
+
close: () => set({ activeFootnoteID: null, anchorElement: null }),
24
24
+
}));
25
25
+
26
26
+
export function FootnotePopover() {
27
27
+
let { activeFootnoteID, anchorElement, close } = useFootnotePopoverStore();
28
28
+
let { footnotes } = useFootnoteContext();
29
29
+
let { permissions } = useEntitySetContext();
30
30
+
let rep = useReplicache();
31
31
+
let popoverRef = useRef<HTMLDivElement>(null);
32
32
+
let [position, setPosition] = useState<{ top: number; left: number; arrowLeft: number } | null>(null);
33
33
+
34
34
+
let footnote = footnotes.find((fn) => fn.footnoteEntityID === activeFootnoteID);
35
35
+
36
36
+
let updatePosition = useCallback(() => {
37
37
+
if (!anchorElement || !popoverRef.current) return;
38
38
+
39
39
+
let anchorRect = anchorElement.getBoundingClientRect();
40
40
+
let popoverWidth = popoverRef.current.offsetWidth;
41
41
+
let popoverHeight = popoverRef.current.offsetHeight;
42
42
+
43
43
+
// Position above the anchor by default, fall back to below
44
44
+
let top = anchorRect.top - popoverHeight - 8;
45
45
+
let left = anchorRect.left + anchorRect.width / 2 - popoverWidth / 2;
46
46
+
47
47
+
// Clamp horizontal position
48
48
+
let padding = 12;
49
49
+
left = Math.max(padding, Math.min(left, window.innerWidth - popoverWidth - padding));
50
50
+
51
51
+
// Arrow position relative to popover
52
52
+
let arrowLeft = anchorRect.left + anchorRect.width / 2 - left;
53
53
+
54
54
+
// If not enough room above, show below
55
55
+
if (top < padding) {
56
56
+
top = anchorRect.bottom + 8;
57
57
+
}
58
58
+
59
59
+
setPosition({ top, left, arrowLeft });
60
60
+
}, [anchorElement]);
61
61
+
62
62
+
useEffect(() => {
63
63
+
if (!activeFootnoteID || !anchorElement) {
64
64
+
setPosition(null);
65
65
+
return;
66
66
+
}
67
67
+
68
68
+
// Delay to let the popover render so we can measure it
69
69
+
requestAnimationFrame(updatePosition);
70
70
+
71
71
+
let handleClickOutside = (e: Event) => {
72
72
+
let target = e.target as Node;
73
73
+
if (
74
74
+
popoverRef.current &&
75
75
+
!popoverRef.current.contains(target) &&
76
76
+
!anchorElement.contains(target)
77
77
+
) {
78
78
+
close();
79
79
+
}
80
80
+
};
81
81
+
82
82
+
let handleScroll = () => close();
83
83
+
84
84
+
document.addEventListener("mousedown", handleClickOutside);
85
85
+
document.addEventListener("touchstart", handleClickOutside);
86
86
+
// Close on scroll of any scroll container
87
87
+
let scrollWrapper = anchorElement.closest(".pageScrollWrapper");
88
88
+
scrollWrapper?.addEventListener("scroll", handleScroll);
89
89
+
window.addEventListener("resize", close);
90
90
+
91
91
+
return () => {
92
92
+
document.removeEventListener("mousedown", handleClickOutside);
93
93
+
document.removeEventListener("touchstart", handleClickOutside);
94
94
+
scrollWrapper?.removeEventListener("scroll", handleScroll);
95
95
+
window.removeEventListener("resize", close);
96
96
+
};
97
97
+
}, [activeFootnoteID, anchorElement, close, updatePosition]);
98
98
+
99
99
+
if (!activeFootnoteID || !footnote) return null;
100
100
+
101
101
+
return (
102
102
+
<div
103
103
+
ref={popoverRef}
104
104
+
className="footnote-popover lg:hidden fixed z-50 bg-bg-page border border-border rounded-lg shadow-md px-3 py-2 w-[min(calc(100vw-24px),320px)]"
105
105
+
style={{
106
106
+
top: position?.top ?? -9999,
107
107
+
left: position?.left ?? -9999,
108
108
+
visibility: position ? "visible" : "hidden",
109
109
+
}}
110
110
+
>
111
111
+
<FootnoteEditor
112
112
+
footnoteEntityID={footnote.footnoteEntityID}
113
113
+
index={footnote.index}
114
114
+
editable={permissions.write}
115
115
+
onDelete={
116
116
+
permissions.write
117
117
+
? () => {
118
118
+
deleteFootnoteFromBlock(
119
119
+
footnote.footnoteEntityID,
120
120
+
footnote.blockID,
121
121
+
rep.rep,
122
122
+
);
123
123
+
close();
124
124
+
}
125
125
+
: undefined
126
126
+
}
127
127
+
/>
128
128
+
</div>
129
129
+
);
130
130
+
}
+40
components/Footnotes/FootnoteSection.tsx
···
1
1
+
import { useFootnoteContext } from "./FootnoteContext";
2
2
+
import { FootnoteEditor } from "./FootnoteEditor";
3
3
+
import { useReplicache } from "src/replicache";
4
4
+
import { useEntitySetContext } from "components/EntitySetProvider";
5
5
+
import { deleteFootnoteFromBlock } from "./deleteFootnoteFromBlock";
6
6
+
7
7
+
export function FootnoteSection(props: { hiddenOnDesktop?: boolean }) {
8
8
+
let { footnotes } = useFootnoteContext();
9
9
+
let { permissions } = useEntitySetContext();
10
10
+
let rep = useReplicache();
11
11
+
12
12
+
if (footnotes.length === 0) return null;
13
13
+
14
14
+
return (
15
15
+
<div className={`footnote-section px-3 sm:px-4 pb-2 ${props.hiddenOnDesktop ? "lg:hidden" : ""}`}>
16
16
+
<hr className="border-border-light mb-3" />
17
17
+
<div className="flex flex-col gap-2">
18
18
+
{footnotes.map((fn) => (
19
19
+
<FootnoteEditor
20
20
+
key={fn.footnoteEntityID}
21
21
+
footnoteEntityID={fn.footnoteEntityID}
22
22
+
index={fn.index}
23
23
+
editable={permissions.write}
24
24
+
onDelete={
25
25
+
permissions.write
26
26
+
? () => {
27
27
+
deleteFootnoteFromBlock(
28
28
+
fn.footnoteEntityID,
29
29
+
fn.blockID,
30
30
+
rep.rep,
31
31
+
);
32
32
+
}
33
33
+
: undefined
34
34
+
}
35
35
+
/>
36
36
+
))}
37
37
+
</div>
38
38
+
</div>
39
39
+
);
40
40
+
}
+207
components/Footnotes/FootnoteSideColumn.tsx
···
1
1
+
import { useEffect, useRef, useState, 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
+
8
8
+
type PositionedFootnote = {
9
9
+
footnoteEntityID: string;
10
10
+
blockID: string;
11
11
+
index: number;
12
12
+
top: number;
13
13
+
};
14
14
+
15
15
+
const GAP = 4;
16
16
+
17
17
+
export function FootnoteSideColumn(props: {
18
18
+
pageEntityID: string;
19
19
+
visible: boolean;
20
20
+
}) {
21
21
+
let { footnotes } = useFootnoteContext();
22
22
+
let { permissions } = useEntitySetContext();
23
23
+
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
68
+
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]);
126
126
+
127
127
+
if (!props.visible || footnotes.length === 0) return null;
128
128
+
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>
161
161
+
);
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
196
+
197
197
+
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>
206
206
+
);
207
207
+
}
+32
components/Footnotes/deleteFootnoteFromBlock.ts
···
1
1
+
import { useEditorStates } from "src/state/useEditorState";
2
2
+
3
3
+
export function deleteFootnoteFromBlock(
4
4
+
footnoteEntityID: string,
5
5
+
blockID: string,
6
6
+
rep: any,
7
7
+
) {
8
8
+
if (!rep) return;
9
9
+
10
10
+
let editorState = useEditorStates.getState().editorStates[blockID];
11
11
+
if (editorState?.view) {
12
12
+
let view = editorState.view;
13
13
+
let { doc } = view.state;
14
14
+
let tr = view.state.tr;
15
15
+
let found = false;
16
16
+
doc.descendants((node, pos) => {
17
17
+
if (
18
18
+
found ||
19
19
+
node.type.name !== "footnote" ||
20
20
+
node.attrs.footnoteEntityID !== footnoteEntityID
21
21
+
)
22
22
+
return;
23
23
+
found = true;
24
24
+
tr.delete(pos, pos + node.nodeSize);
25
25
+
});
26
26
+
if (found) {
27
27
+
view.dispatch(tr);
28
28
+
}
29
29
+
}
30
30
+
31
31
+
rep.mutate.deleteFootnote({ footnoteEntityID, blockID });
32
32
+
}
+48
components/Footnotes/usePageFootnotes.ts
···
1
1
+
import { useReplicache } from "src/replicache";
2
2
+
import { useSubscribe } from "src/replicache/useSubscribe";
3
3
+
import { scanIndex } from "src/replicache/utils";
4
4
+
5
5
+
export type FootnoteInfo = {
6
6
+
footnoteEntityID: string;
7
7
+
blockID: string;
8
8
+
index: number;
9
9
+
};
10
10
+
11
11
+
export function usePageFootnotes(pageID: string) {
12
12
+
let rep = useReplicache();
13
13
+
let data = useSubscribe(
14
14
+
rep?.rep,
15
15
+
async (tx) => {
16
16
+
let scan = scanIndex(tx);
17
17
+
let blocks = await scan.eav(pageID, "card/block");
18
18
+
let sorted = blocks.toSorted((a, b) =>
19
19
+
a.data.position > b.data.position ? 1 : -1,
20
20
+
);
21
21
+
22
22
+
let footnotes: FootnoteInfo[] = [];
23
23
+
let indexMap: Record<string, number> = {};
24
24
+
let idx = 1;
25
25
+
26
26
+
for (let block of sorted) {
27
27
+
let blockFootnotes = await scan.eav(block.data.value, "block/footnote");
28
28
+
let sortedFootnotes = blockFootnotes.toSorted((a, b) =>
29
29
+
a.data.position > b.data.position ? 1 : -1,
30
30
+
);
31
31
+
for (let fn of sortedFootnotes) {
32
32
+
footnotes.push({
33
33
+
footnoteEntityID: fn.data.value,
34
34
+
blockID: block.data.value,
35
35
+
index: idx,
36
36
+
});
37
37
+
indexMap[fn.data.value] = idx;
38
38
+
idx++;
39
39
+
}
40
40
+
}
41
41
+
42
42
+
return { footnotes, indexMap };
43
43
+
},
44
44
+
{ dependencies: [pageID] },
45
45
+
);
46
46
+
47
47
+
return data || { footnotes: [], indexMap: {} as Record<string, number> };
48
48
+
}
+53
-28
components/Pages/Page.tsx
···
17
17
import { CardThemeProvider } from "components/ThemeManager/ThemeProvider";
18
18
import { useDrawerOpen } from "app/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer";
19
19
import { usePreserveScroll } from "src/hooks/usePreserveScroll";
20
20
+
import { usePageFootnotes } from "components/Footnotes/usePageFootnotes";
21
21
+
import { FootnoteContext } from "components/Footnotes/FootnoteContext";
22
22
+
import { FootnoteSection } from "components/Footnotes/FootnoteSection";
23
23
+
import { FootnoteSideColumn } from "components/Footnotes/FootnoteSideColumn";
24
24
+
import { FootnotePopover } from "components/Footnotes/FootnotePopover";
20
25
21
26
export function Page(props: {
22
27
entityID: string;
···
36
41
let pageType = useEntity(props.entityID, "page/type")?.data.value || "doc";
37
42
38
43
let drawerOpen = useDrawerOpen(props.entityID);
44
44
+
let footnoteData = usePageFootnotes(props.entityID);
45
45
+
let isRightmostPage = useUIState((s) => {
46
46
+
let pages = s.openPages;
47
47
+
if (pages.length === 0) return true;
48
48
+
return pages[pages.length - 1] === props.entityID;
49
49
+
});
50
50
+
let sideColumnVisible = pageType === "doc" && !drawerOpen && isRightmostPage;
51
51
+
39
52
return (
40
53
<CardThemeProvider entityID={props.entityID}>
41
41
-
<PageWrapper
42
42
-
onClickAction={(e) => {
43
43
-
if (e.defaultPrevented) return;
44
44
-
if (rep) {
45
45
-
if (isFocused) return;
46
46
-
focusPage(props.entityID, rep);
54
54
+
<FootnoteContext.Provider value={footnoteData}>
55
55
+
<PageWrapper
56
56
+
onClickAction={(e) => {
57
57
+
if (e.defaultPrevented) return;
58
58
+
if (rep) {
59
59
+
if (isFocused) return;
60
60
+
focusPage(props.entityID, rep);
61
61
+
}
62
62
+
}}
63
63
+
id={elementId.page(props.entityID).container}
64
64
+
drawerOpen={!!drawerOpen}
65
65
+
isFocused={isFocused}
66
66
+
fullPageScroll={props.fullPageScroll}
67
67
+
pageType={pageType}
68
68
+
pageOptions={
69
69
+
<PageOptions
70
70
+
entityID={props.entityID}
71
71
+
first={props.first}
72
72
+
isFocused={isFocused}
73
73
+
/>
74
74
+
}
75
75
+
footnoteSideColumn={
76
76
+
<FootnoteSideColumn
77
77
+
pageEntityID={props.entityID}
78
78
+
visible={sideColumnVisible}
79
79
+
/>
47
80
}
48
48
-
}}
49
49
-
id={elementId.page(props.entityID).container}
50
50
-
drawerOpen={!!drawerOpen}
51
51
-
isFocused={isFocused}
52
52
-
fullPageScroll={props.fullPageScroll}
53
53
-
pageType={pageType}
54
54
-
pageOptions={
55
55
-
<PageOptions
56
56
-
entityID={props.entityID}
57
57
-
first={props.first}
58
58
-
isFocused={isFocused}
59
59
-
/>
60
60
-
}
61
61
-
>
62
62
-
{props.first && pageType === "doc" && (
63
63
-
<>
64
64
-
<PublicationMetadata />
65
65
-
</>
66
66
-
)}
67
67
-
<PageContent entityID={props.entityID} first={props.first} />
68
68
-
</PageWrapper>
69
69
-
<DesktopPageFooter pageID={props.entityID} />
81
81
+
>
82
82
+
{props.first && pageType === "doc" && (
83
83
+
<>
84
84
+
<PublicationMetadata />
85
85
+
</>
86
86
+
)}
87
87
+
<PageContent entityID={props.entityID} first={props.first} />
88
88
+
</PageWrapper>
89
89
+
<DesktopPageFooter pageID={props.entityID} />
90
90
+
<FootnotePopover />
91
91
+
</FootnoteContext.Provider>
70
92
</CardThemeProvider>
71
93
);
72
94
}
···
75
97
id: string;
76
98
children: React.ReactNode;
77
99
pageOptions?: React.ReactNode;
100
100
+
footnoteSideColumn?: React.ReactNode;
78
101
fullPageScroll: boolean;
79
102
isFocused?: boolean;
80
103
onClickAction?: (e: React.MouseEvent) => void;
···
132
155
</div>
133
156
</div>
134
157
{props.pageOptions}
158
158
+
{props.footnoteSideColumn}
135
159
</div>
136
160
);
137
161
};
···
205
229
/>
206
230
) : null}
207
231
<Blocks entityID={props.entityID} />
232
232
+
<FootnoteSection />
208
233
<div className="h-4 sm:h-6 w-full" />
209
234
{/* we handle page bg in this sepate div so that
210
235
we can apply an opacity the background image
+67
components/Toolbar/FootnoteButton.tsx
···
1
1
+
import { useEditorStates } from "src/state/useEditorState";
2
2
+
import { useUIState } from "src/useUIState";
3
3
+
import { useReplicache } from "src/replicache";
4
4
+
import { useEntitySetContext } from "components/EntitySetProvider";
5
5
+
import { insertFootnote } from "components/Blocks/TextBlock/insertFootnote";
6
6
+
import { TooltipButton } from "components/Buttons";
7
7
+
import { Props } from "components/Icons/Props";
8
8
+
9
9
+
export function FootnoteButton() {
10
10
+
let rep = useReplicache();
11
11
+
let entity_set = useEntitySetContext();
12
12
+
let focusedBlock = useUIState((s) => s.focusedEntity);
13
13
+
14
14
+
return (
15
15
+
<TooltipButton
16
16
+
tooltipContent={<div className="text-center">Footnote</div>}
17
17
+
onMouseDown={async (e) => {
18
18
+
e.preventDefault();
19
19
+
if (!focusedBlock || focusedBlock.entityType !== "block") return;
20
20
+
let editorState =
21
21
+
useEditorStates.getState().editorStates[focusedBlock.entityID];
22
22
+
if (!editorState?.view || !rep.rep) return;
23
23
+
await insertFootnote(
24
24
+
editorState.view,
25
25
+
focusedBlock.entityID,
26
26
+
rep.rep,
27
27
+
entity_set.set,
28
28
+
);
29
29
+
}}
30
30
+
>
31
31
+
<FootnoteIcon />
32
32
+
</TooltipButton>
33
33
+
);
34
34
+
}
35
35
+
36
36
+
function FootnoteIcon(props: Props) {
37
37
+
return (
38
38
+
<svg
39
39
+
width="24"
40
40
+
height="24"
41
41
+
viewBox="0 0 24 24"
42
42
+
fill="none"
43
43
+
xmlns="http://www.w3.org/2000/svg"
44
44
+
{...props}
45
45
+
>
46
46
+
<text
47
47
+
x="6"
48
48
+
y="18"
49
49
+
fontSize="14"
50
50
+
fontWeight="bold"
51
51
+
fill="currentColor"
52
52
+
fontFamily="serif"
53
53
+
>
54
54
+
f
55
55
+
</text>
56
56
+
<text
57
57
+
x="14"
58
58
+
y="12"
59
59
+
fontSize="9"
60
60
+
fill="currentColor"
61
61
+
fontFamily="sans-serif"
62
62
+
>
63
63
+
n
64
64
+
</text>
65
65
+
</svg>
66
66
+
);
67
67
+
}
+2
components/Toolbar/TextToolbar.tsx
···
8
8
import { ToolbarTypes } from ".";
9
9
import { schema } from "components/Blocks/TextBlock/schema";
10
10
import { TextAlignmentButton } from "./TextAlignmentToolbar";
11
11
+
import { FootnoteButton } from "./FootnoteButton";
11
12
import { Props } from "components/Icons/Props";
12
13
import { isMac } from "src/utils/isDevice";
13
14
···
80
81
<TextAlignmentButton setToolbarState={props.setToolbarState} />
81
82
<ListButton setToolbarState={props.setToolbarState} />
82
83
<Separator classname="h-6!" />
84
84
+
<FootnoteButton />
83
85
</>
84
86
);
85
87
};
+21
lexicons/api/lexicons.ts
···
2027
2027
'lex:pub.leaflet.richtext.facet#id',
2028
2028
'lex:pub.leaflet.richtext.facet#bold',
2029
2029
'lex:pub.leaflet.richtext.facet#italic',
2030
2030
+
'lex:pub.leaflet.richtext.facet#footnote',
2030
2031
],
2031
2032
},
2032
2033
},
···
2127
2128
description: 'Facet feature for italic text',
2128
2129
required: [],
2129
2130
properties: {},
2131
2131
+
},
2132
2132
+
footnote: {
2133
2133
+
type: 'object',
2134
2134
+
description: 'Facet feature for a footnote reference',
2135
2135
+
required: ['footnoteId', 'contentPlaintext'],
2136
2136
+
properties: {
2137
2137
+
footnoteId: {
2138
2138
+
type: 'string',
2139
2139
+
},
2140
2140
+
contentPlaintext: {
2141
2141
+
type: 'string',
2142
2142
+
},
2143
2143
+
contentFacets: {
2144
2144
+
type: 'array',
2145
2145
+
items: {
2146
2146
+
type: 'ref',
2147
2147
+
ref: 'lex:pub.leaflet.richtext.facet',
2148
2148
+
},
2149
2149
+
},
2150
2150
+
},
2130
2151
},
2131
2152
},
2132
2153
},
+19
lexicons/api/types/pub/leaflet/richtext/facet.ts
···
29
29
| $Typed<Id>
30
30
| $Typed<Bold>
31
31
| $Typed<Italic>
32
32
+
| $Typed<Footnote>
32
33
| { $type: string }
33
34
)[]
34
35
}
···
213
214
export function validateItalic<V>(v: V) {
214
215
return validate<Italic & V>(v, id, hashItalic)
215
216
}
217
217
+
218
218
+
/** Facet feature for a footnote reference */
219
219
+
export interface Footnote {
220
220
+
$type?: 'pub.leaflet.richtext.facet#footnote'
221
221
+
footnoteId: string
222
222
+
contentPlaintext: string
223
223
+
contentFacets?: Main[]
224
224
+
}
225
225
+
226
226
+
const hashFootnote = 'footnote'
227
227
+
228
228
+
export function isFootnote<V>(v: V) {
229
229
+
return is$typed(v, id, hashFootnote)
230
230
+
}
231
231
+
232
232
+
export function validateFootnote<V>(v: V) {
233
233
+
return validate<Footnote & V>(v, id, hashFootnote)
234
234
+
}
+22
-1
lexicons/pub/leaflet/richtext/facet.json
···
28
28
"#strikethrough",
29
29
"#id",
30
30
"#bold",
31
31
-
"#italic"
31
31
+
"#italic",
32
32
+
"#footnote"
32
33
]
33
34
}
34
35
}
···
135
136
"description": "Facet feature for italic text",
136
137
"required": [],
137
138
"properties": {}
139
139
+
},
140
140
+
"footnote": {
141
141
+
"type": "object",
142
142
+
"description": "Facet feature for a footnote reference",
143
143
+
"required": ["footnoteId", "contentPlaintext"],
144
144
+
"properties": {
145
145
+
"footnoteId": {
146
146
+
"type": "string"
147
147
+
},
148
148
+
"contentPlaintext": {
149
149
+
"type": "string"
150
150
+
},
151
151
+
"contentFacets": {
152
152
+
"type": "array",
153
153
+
"items": {
154
154
+
"type": "ref",
155
155
+
"ref": "#main"
156
156
+
}
157
157
+
}
158
158
+
}
138
159
}
139
160
}
140
161
}
+4
src/replicache/attributes.ts
···
107
107
type: "number",
108
108
cardinality: "one",
109
109
},
110
110
+
"block/footnote": {
111
111
+
type: "ordered-reference",
112
112
+
cardinality: "many",
113
113
+
},
110
114
} as const;
111
115
112
116
const MailboxAttributes = {
+33
src/replicache/mutations.ts
···
720
720
});
721
721
};
722
722
723
723
+
const createFootnote: Mutation<{
724
724
+
footnoteEntityID: string;
725
725
+
blockID: string;
726
726
+
permission_set: string;
727
727
+
position: string;
728
728
+
}> = async (args, ctx) => {
729
729
+
await ctx.createEntity({
730
730
+
entityID: args.footnoteEntityID,
731
731
+
permission_set: args.permission_set,
732
732
+
});
733
733
+
await ctx.assertFact({
734
734
+
entity: args.blockID,
735
735
+
attribute: "block/footnote",
736
736
+
data: {
737
737
+
type: "ordered-reference",
738
738
+
value: args.footnoteEntityID,
739
739
+
position: args.position,
740
740
+
},
741
741
+
});
742
742
+
};
743
743
+
744
744
+
const deleteFootnote: Mutation<{
745
745
+
footnoteEntityID: string;
746
746
+
blockID: string;
747
747
+
}> = async (args, ctx) => {
748
748
+
let footnotes = await ctx.scanIndex.eav(args.blockID, "block/footnote");
749
749
+
let fact = footnotes.find((f) => f.data.value === args.footnoteEntityID);
750
750
+
if (fact) await ctx.retractFact(fact.id);
751
751
+
await ctx.deleteEntity(args.footnoteEntityID);
752
752
+
};
753
753
+
723
754
export const mutations = {
724
755
retractAttribute,
725
756
addBlock,
···
743
774
addPollOption,
744
775
removePollOption,
745
776
updatePublicationDraft,
777
777
+
createFootnote,
778
778
+
deleteFootnote,
746
779
};
+13
src/utils/deleteBlock.ts
···
113
113
// close the pages
114
114
pagesToClose.forEach((page) => page && useUIState.getState().closePage(page));
115
115
116
116
+
// Clean up footnotes from blocks being deleted
117
117
+
for (let entity of entities) {
118
118
+
let footnotes = await rep.query((tx) =>
119
119
+
scanIndex(tx).eav(entity, "block/footnote"),
120
120
+
);
121
121
+
for (let fn of footnotes) {
122
122
+
await rep.mutate.deleteFootnote({
123
123
+
footnoteEntityID: fn.data.value,
124
124
+
blockID: entity,
125
125
+
});
126
126
+
}
127
127
+
}
128
128
+
116
129
await Promise.all(
117
130
entities.map((entity) =>
118
131
rep?.mutate.removeBlock({
+4
src/utils/yjsFragmentToString.ts
···
25
25
if (node.nodeName === "didMention" || node.nodeName === "atMention") {
26
26
return node.getAttribute("text") || "";
27
27
}
28
28
+
// Handle footnote nodes - emit placeholder
29
29
+
if (node.nodeName === "footnote") {
30
30
+
return "*";
31
31
+
}
28
32
return node
29
33
.toArray()
30
34
.map((f) => YJSFragmentToString(f))