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