a tool for shared writing and social publishing

Add footnotes!

Squashed commit of the following:

commit 0f22f24c5ff5ed98ab139fc609632bfef20fd280
Merge: 26876031 66f9d441
Author: Jared Pereira <jared@awarm.space>
Date: Fri Mar 6 15:55:34 2026 -0500

Merge branch 'main' into feature/footnotes

commit 268760316c02f10d545f7f81b9438cc76981bb7c
Author: Jared Pereira <jared@awarm.space>
Date: Fri Mar 6 15:38:32 2026 -0500

open footnote popover

commit 5add9002932f24b2cb3e88a79415359e5bfdc1aa
Author: Jared Pereira <jared@awarm.space>
Date: Fri Mar 6 15:29:28 2026 -0500

try mask-image css

commit 10f35a1361dfb4672b03391b91090b368c5abbf9
Author: Jared Pereira <jared@awarm.space>
Date: Fri Mar 6 15:16:33 2026 -0500

make footnotes text sm

commit 3ae07878e7284b1112d177c4d787d27e1f2726f4
Author: Jared Pereira <jared@awarm.space>
Date: Fri Mar 6 14:31:46 2026 -0500

fix the type errors

commit 2aa857ba975fa5df90629984550d3aa7ef47d402
Author: Jared Pereira <jared@awarm.space>
Date: Fri Mar 6 11:45:01 2026 -0500

fix footnote rendering and counts

commit 212fabd22c77d12e71530403eb6b5bbbdbef1ae3
Author: celine <celine@hyperlink.academy>
Date: Thu Mar 5 22:24:39 2026 -0500

autofocus editor when footnote is selecter

commit ce10ee81ff666d1c5f33e927719d0c5a840627ae
Author: celine <celine@hyperlink.academy>
Date: Thu Mar 5 21:56:21 2026 -0500

some styling updates

commit 4a8480f35afe12c2703f07a19579e06ec3f509fb
Author: Jared Pereira <jared@awarm.space>
Date: Thu Mar 5 18:58:14 2026 -0500

add toolbar stuff

commit bf1a2f50bb31336fefd67d47d11e670aeabf9da4
Author: Jared Pereira <jared@awarm.space>
Date: Thu Mar 5 18:06:11 2026 -0500

fix popover number and threshold

commit aae6fae776da6570a26e6de12fab119ef8510bc7
Author: Jared Pereira <jared@awarm.space>
Date: Thu Mar 5 16:22:40 2026 -0500

share footnote item layout

commit 0f843a5a899eacd98d17f11412d07ca91b74841e
Author: Jared Pereira <jared@awarm.space>
Date: Thu Mar 5 16:15:25 2026 -0500

share editor logic between footnote and textblock

commit 16f8052758e6aae9157ab97014509ef010eb717f
Author: Jared Pereira <jared@awarm.space>
Date: Thu Mar 5 16:15:17 2026 -0500

render footnotes properly in published page

commit 713d14b48eeb5a2ceb44a71099b3d912b9f3ba6c
Author: Jared Pereira <jared@awarm.space>
Date: Thu Mar 5 15:53:39 2026 -0500

add footnotes

+2050 -66
+39 -2
actions/publishToPublication.ts
··· 485 485 ).flat(); 486 486 } 487 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 + }; 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 - let { facets } = YJSFragmentToFacets(nodes[0]); 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 + footnoteContentResolver?: (footnoteEntityID: string) => { plaintext: string; facets: PubLeafletRichtextFacet.Main[] }, 762 774 ): { facets: PubLeafletRichtextFacet.Main[]; byteLength: number } { 763 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 + 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 - const result = YJSFragmentToFacets(child, currentOffset); 847 + const result = YJSFragmentToFacets(child, currentOffset, footnoteContentResolver); 811 848 allFacets.push(...result.facets); 812 849 currentOffset += result.byteLength; 813 850 }
+12
app/[leaflet_id]/Footer.tsx
··· 4 4 import { Media } from "components/Media"; 5 5 import { ThemePopover } from "components/ThemeManager/ThemeSetter"; 6 6 import { Toolbar } from "components/Toolbar"; 7 + import { FootnoteToolbar } from "components/Toolbar/FootnoteToolbarWrapper"; 7 8 import { ShareOptions } from "app/[leaflet_id]/actions/ShareOptions"; 8 9 import { HomeButton } from "app/[leaflet_id]/actions/HomeButton"; 9 10 import { PublishButton } from "./actions/PublishButton"; ··· 55 56 blockID={focusedBlock.entityID} 56 57 blockType={blockType} 57 58 /> 59 + </div> 60 + ) : focusedBlock && 61 + focusedBlock.entityType === "footnote" && 62 + entity_set.permissions.write ? ( 63 + <div 64 + className="w-full z-10 p-2 flex bg-bg-page pwa-padding-bottom" 65 + onMouseDown={(e) => { 66 + if (e.currentTarget === e.target) e.preventDefault(); 67 + }} 68 + > 69 + <FootnoteToolbar pageID={focusedBlock.parent} /> 58 70 </div> 59 71 ) : entity_set.permissions.write ? ( 60 72 <Footer>
+53
app/globals.css
··· 514 514 animation-iteration-count: 1; 515 515 animation-timing-function: ease-in; 516 516 } 517 + 518 + .footnote-scope { 519 + counter-reset: footnote; 520 + } 521 + .footnote-ref { 522 + counter-increment: footnote; 523 + cursor: pointer; 524 + color: rgb(var(--color-tertiary)); 525 + opacity: 0.7; 526 + } 527 + .footnote-ref::after { 528 + content: counter(footnote); 529 + vertical-align: super; 530 + padding-left: 2px; 531 + font-size: 75%; 532 + line-height: 0rem; 533 + } 534 + .footnote-ref ~ br.ProseMirror-trailingBreak { 535 + display: inline; 536 + width: 4px; 537 + } 538 + .footnote-ref ~ img.ProseMirror-separator { 539 + display: none; 540 + } 541 + 542 + .footnote-side-enter { 543 + animation: footnote-fade-in 200ms ease-out; 544 + } 545 + @keyframes footnote-fade-in { 546 + from { 547 + opacity: 0; 548 + transform: translateX(-8px); 549 + } 550 + to { 551 + opacity: 1; 552 + transform: translateX(0); 553 + } 554 + } 555 + 556 + .footnote-side-item { 557 + max-height: 4.5em; 558 + overflow: hidden; 559 + transition: max-height 200ms ease, mask-image 200ms ease; 560 + } 561 + .footnote-side-item.has-overflow { 562 + mask-image: linear-gradient(to bottom, white 50%, transparent 100%); 563 + } 564 + .footnote-side-item:hover, 565 + .footnote-side-item:focus-within, 566 + .footnote-side-item.footnote-side-focused { 567 + max-height: 40em; 568 + mask-image: none; 569 + }
+3
app/lish/[did]/[publication]/[rkey]/Blocks/BaseTextBlock.tsx
··· 5 5 RichText, 6 6 } from "../Blocks/TextBlockCore"; 7 7 import { ReactNode } from "react"; 8 + import { PublishedFootnoteRefRenderer } from "../Footnotes/PublishedFootnotePopover"; 8 9 9 10 // Re-export RichText for backwards compatibility 10 11 export { RichText }; ··· 19 20 {...props} 20 21 renderers={{ 21 22 DidMention: DidMentionWithPopover, 23 + FootnoteRef: PublishedFootnoteRefRenderer, 22 24 }} 25 + footnoteIndexMap={props.footnoteIndexMap} 23 26 /> 24 27 ); 25 28 }
+2 -1
app/lish/[did]/[publication]/[rkey]/Blocks/TextBlock.tsx
··· 12 12 index: number[]; 13 13 preview?: boolean; 14 14 pageId?: string; 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 - return <BaseTextBlock {...props} facets={facets} />; 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 + 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 + 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 + let isFootnote = segment.facet?.find(PubLeafletRichtextFacet.isFootnote); 41 44 let isHighlighted = segment.facet?.find( 42 45 PubLeafletRichtextFacet.isHighlight, 43 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 + 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 + "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 + }
+163
app/lish/[did]/[publication]/[rkey]/Footnotes/PublishedFootnotePopover.tsx
··· 1 + "use client"; 2 + 3 + import { useEffect, useRef, useState, useCallback, ReactNode } from "react"; 4 + import { create } from "zustand"; 5 + import { TextBlockCore } from "../Blocks/TextBlockCore"; 6 + import { PubLeafletRichtextFacet } from "lexicons/api"; 7 + 8 + type PublishedFootnoteData = { 9 + footnoteId: string; 10 + index: number; 11 + contentPlaintext: string; 12 + contentFacets?: PubLeafletRichtextFacet.Main[]; 13 + }; 14 + 15 + type PopoverState = { 16 + activeFootnoteId: string | null; 17 + anchorElement: HTMLElement | null; 18 + open: (footnoteId: string, anchor: HTMLElement) => void; 19 + close: () => void; 20 + }; 21 + 22 + export const usePublishedFootnotePopoverStore = create<PopoverState>( 23 + (set) => ({ 24 + activeFootnoteId: null, 25 + anchorElement: null, 26 + open: (footnoteId, anchor) => 27 + set({ activeFootnoteId: footnoteId, anchorElement: anchor }), 28 + close: () => set({ activeFootnoteId: null, anchorElement: null }), 29 + }), 30 + ); 31 + 32 + export function PublishedFootnoteRefRenderer(props: { 33 + footnoteId: string; 34 + index: number; 35 + children: ReactNode; 36 + }) { 37 + let ref = useRef<HTMLElement>(null); 38 + return ( 39 + <sup 40 + ref={ref} 41 + className="text-accent-contrast cursor-pointer" 42 + id={`fnref-${props.footnoteId}`} 43 + onClick={(e) => { 44 + e.preventDefault(); 45 + let store = usePublishedFootnotePopoverStore.getState(); 46 + if (store.activeFootnoteId === props.footnoteId) { 47 + store.close(); 48 + } else { 49 + store.open(props.footnoteId, e.currentTarget); 50 + } 51 + }} 52 + > 53 + {props.index} 54 + </sup> 55 + ); 56 + } 57 + 58 + export function PublishedFootnotePopover(props: { 59 + footnotes: PublishedFootnoteData[]; 60 + }) { 61 + let { activeFootnoteId, anchorElement, close } = 62 + usePublishedFootnotePopoverStore(); 63 + let popoverRef = useRef<HTMLDivElement>(null); 64 + let [position, setPosition] = useState<{ 65 + top: number; 66 + left: number; 67 + arrowLeft: number; 68 + } | null>(null); 69 + 70 + let footnote = props.footnotes.find( 71 + (fn) => fn.footnoteId === activeFootnoteId, 72 + ); 73 + 74 + let updatePosition = useCallback(() => { 75 + if (!anchorElement || !popoverRef.current) return; 76 + 77 + let anchorRect = anchorElement.getBoundingClientRect(); 78 + let popoverWidth = popoverRef.current.offsetWidth; 79 + let popoverHeight = popoverRef.current.offsetHeight; 80 + 81 + let top = anchorRect.top - popoverHeight - 8; 82 + let left = anchorRect.left + anchorRect.width / 2 - popoverWidth / 2; 83 + 84 + let padding = 12; 85 + left = Math.max( 86 + padding, 87 + Math.min(left, window.innerWidth - popoverWidth - padding), 88 + ); 89 + 90 + let arrowLeft = anchorRect.left + anchorRect.width / 2 - left; 91 + 92 + if (top < padding) { 93 + top = anchorRect.bottom + 8; 94 + } 95 + 96 + setPosition({ top, left, arrowLeft }); 97 + }, [anchorElement]); 98 + 99 + useEffect(() => { 100 + if (!activeFootnoteId || !anchorElement) { 101 + setPosition(null); 102 + return; 103 + } 104 + 105 + requestAnimationFrame(updatePosition); 106 + 107 + let handleClickOutside = (e: Event) => { 108 + let target = e.target as Node; 109 + if ( 110 + popoverRef.current && 111 + !popoverRef.current.contains(target) && 112 + !anchorElement.contains(target) 113 + ) { 114 + close(); 115 + } 116 + }; 117 + 118 + let handleScroll = () => close(); 119 + 120 + document.addEventListener("mousedown", handleClickOutside); 121 + document.addEventListener("touchstart", handleClickOutside); 122 + window.addEventListener("scroll", handleScroll, true); 123 + window.addEventListener("resize", close); 124 + 125 + return () => { 126 + document.removeEventListener("mousedown", handleClickOutside); 127 + document.removeEventListener("touchstart", handleClickOutside); 128 + window.removeEventListener("scroll", handleScroll, true); 129 + window.removeEventListener("resize", close); 130 + }; 131 + }, [activeFootnoteId, anchorElement, close, updatePosition]); 132 + 133 + if (!activeFootnoteId || !footnote) return null; 134 + 135 + return ( 136 + <div 137 + ref={popoverRef} 138 + className="footnote-popover fixed z-50 bg-bg-page border border-border rounded-lg shadow-md px-3 py-2 w-[min(calc(100vw-24px),320px)]" 139 + style={{ 140 + top: position?.top ?? -9999, 141 + left: position?.left ?? -9999, 142 + visibility: position ? "visible" : "hidden", 143 + }} 144 + > 145 + <div className="flex gap-2 items-start text-sm"> 146 + <span className="text-accent-contrast font-bold shrink-0"> 147 + {footnote.index} 148 + </span> 149 + <div className="min-w-0"> 150 + {footnote.contentPlaintext ? ( 151 + <TextBlockCore 152 + plaintext={footnote.contentPlaintext} 153 + facets={footnote.contentFacets} 154 + index={[]} 155 + /> 156 + ) : ( 157 + <span className="italic text-tertiary">Empty footnote</span> 158 + )} 159 + </div> 160 + </div> 161 + </div> 162 + ); 163 + }
+56
app/lish/[did]/[publication]/[rkey]/Footnotes/PublishedFootnoteSideColumn.tsx
··· 1 + "use client"; 2 + 3 + import { useCallback } from "react"; 4 + import { PublishedFootnote } from "./PublishedFootnotes"; 5 + import { TextBlockCore } from "../Blocks/TextBlockCore"; 6 + import { FootnoteSideColumnLayout } from "components/Footnotes/FootnoteSideColumnLayout"; 7 + import { FootnoteItemLayout } from "components/Footnotes/FootnoteItemLayout"; 8 + 9 + type PublishedFootnoteItem = PublishedFootnote & { 10 + id: string; 11 + }; 12 + 13 + export function PublishedFootnoteSideColumn(props: { 14 + footnotes: PublishedFootnote[]; 15 + fullPageScroll?: boolean; 16 + }) { 17 + let items: PublishedFootnoteItem[] = props.footnotes.map((fn) => ({ 18 + ...fn, 19 + id: fn.footnoteId, 20 + })); 21 + 22 + let getAnchorSelector = useCallback( 23 + (item: PublishedFootnoteItem) => `#fnref-${item.id}`, 24 + [], 25 + ); 26 + 27 + let renderItem = useCallback( 28 + (item: PublishedFootnoteItem & { top: number }) => ( 29 + <FootnoteItemLayout 30 + index={item.index} 31 + indexHref={`#fnref-${item.footnoteId}`} 32 + > 33 + {item.contentPlaintext ? ( 34 + <TextBlockCore 35 + plaintext={item.contentPlaintext} 36 + facets={item.contentFacets} 37 + index={[]} 38 + /> 39 + ) : ( 40 + <span className="italic text-tertiary">Empty footnote</span> 41 + )} 42 + </FootnoteItemLayout> 43 + ), 44 + [], 45 + ); 46 + 47 + return ( 48 + <FootnoteSideColumnLayout 49 + items={items} 50 + visible={props.footnotes.length > 0} 51 + fullPageScroll={props.fullPageScroll} 52 + getAnchorSelector={getAnchorSelector} 53 + renderItem={renderItem} 54 + /> 55 + ); 56 + }
+120
app/lish/[did]/[publication]/[rkey]/Footnotes/PublishedFootnotes.tsx
··· 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 + import { 12 + FootnoteItemLayout, 13 + FootnoteSectionLayout, 14 + } from "components/Footnotes/FootnoteItemLayout"; 15 + 16 + export type PublishedFootnote = { 17 + footnoteId: string; 18 + index: number; 19 + contentPlaintext: string; 20 + contentFacets?: PubLeafletRichtextFacet.Main[]; 21 + }; 22 + 23 + export function collectFootnotesFromBlocks( 24 + blocks: PubLeafletPagesLinearDocument.Block[], 25 + ): PublishedFootnote[] { 26 + let footnotes: PublishedFootnote[] = []; 27 + let seen = new Set<string>(); 28 + let idx = 1; 29 + 30 + function scanFacets(facets?: PubLeafletRichtextFacet.Main[]) { 31 + if (!facets) return; 32 + for (let facet of facets) { 33 + for (let feature of facet.features) { 34 + if (PubLeafletRichtextFacet.isFootnote(feature)) { 35 + if (!seen.has(feature.footnoteId)) { 36 + seen.add(feature.footnoteId); 37 + footnotes.push({ 38 + footnoteId: feature.footnoteId, 39 + index: idx++, 40 + contentPlaintext: feature.contentPlaintext, 41 + contentFacets: feature.contentFacets, 42 + }); 43 + } 44 + } 45 + } 46 + } 47 + } 48 + 49 + for (let b of blocks) { 50 + let block = b.block; 51 + let facets: PubLeafletRichtextFacet.Main[] | undefined; 52 + if (PubLeafletBlocksText.isMain(block)) { 53 + facets = block.facets; 54 + } else if (PubLeafletBlocksHeader.isMain(block)) { 55 + facets = block.facets; 56 + } else if (PubLeafletBlocksBlockquote.isMain(block)) { 57 + facets = block.facets; 58 + } 59 + if (facets) scanFacets(facets); 60 + } 61 + 62 + return footnotes; 63 + } 64 + 65 + export function buildFootnoteIndexMap( 66 + footnotes: PublishedFootnote[], 67 + ): Map<string, number> { 68 + let map = new Map<string, number>(); 69 + for (let fn of footnotes) { 70 + map.set(fn.footnoteId, fn.index); 71 + } 72 + return map; 73 + } 74 + 75 + export function PublishedFootnoteSection(props: { 76 + footnotes: PublishedFootnote[]; 77 + }) { 78 + if (props.footnotes.length === 0) return null; 79 + 80 + return ( 81 + <FootnoteSectionLayout className="mt-4"> 82 + {props.footnotes.map((fn) => ( 83 + <PublishedFootnoteItem key={fn.footnoteId} footnote={fn} /> 84 + ))} 85 + </FootnoteSectionLayout> 86 + ); 87 + } 88 + 89 + export function PublishedFootnoteItem(props: { 90 + footnote: PublishedFootnote; 91 + }) { 92 + let fn = props.footnote; 93 + return ( 94 + <FootnoteItemLayout 95 + index={fn.index} 96 + indexHref={`#fnref-${fn.footnoteId}`} 97 + id={`fn-${fn.footnoteId}`} 98 + trailing={ 99 + <a 100 + href={`#fnref-${fn.footnoteId}`} 101 + className="text-accent-contrast shrink-0 mt-0.5 text-xs no-underline hover:underline" 102 + title="Back to text" 103 + aria-label={`Back to footnote ${fn.index} in text`} 104 + > 105 + 106 + </a> 107 + } 108 + > 109 + {fn.contentPlaintext ? ( 110 + <TextBlockCore 111 + plaintext={fn.contentPlaintext} 112 + facets={fn.contentFacets} 113 + index={[]} 114 + /> 115 + ) : ( 116 + <span className="italic text-tertiary">Empty footnote</span> 117 + )} 118 + </FootnoteItemLayout> 119 + ); 120 + }
+17
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 + import { 26 + collectFootnotesFromBlocks, 27 + buildFootnoteIndexMap, 28 + PublishedFootnoteSection, 29 + } from "./Footnotes/PublishedFootnotes"; 30 + import { PublishedFootnoteSideColumn } from "./Footnotes/PublishedFootnoteSideColumn"; 31 + import { PublishedFootnotePopover } from "./Footnotes/PublishedFootnotePopover"; 25 32 26 33 export function LinearDocumentPage({ 27 34 blocks, ··· 47 54 } = props; 48 55 let drawer = useDrawerOpen(document_uri); 49 56 const { pages } = useLeafletContent(); 57 + const footnotes = collectFootnotesFromBlocks(blocks); 58 + const footnoteIndexMap = buildFootnoteIndexMap(footnotes); 50 59 51 60 if (!document) return null; 52 61 ··· 62 71 !!drawer && (pageId ? drawer.pageId === pageId : !drawer.pageId) 63 72 } 64 73 pageOptions={pageOptions} 74 + footnoteSideColumn={ 75 + !props.hasContentToRight ? ( 76 + <PublishedFootnoteSideColumn footnotes={footnotes} fullPageScroll={fullPageScroll} /> 77 + ) : undefined 78 + } 65 79 > 66 80 {!isSubpage && profile && ( 67 81 <PostHeader ··· 78 92 blocks={blocks} 79 93 did={did} 80 94 prerenderedCodeBlocks={prerenderedCodeBlocks} 95 + footnoteIndexMap={footnoteIndexMap} 81 96 /> 97 + <PublishedFootnoteSection footnotes={footnotes} /> 82 98 <PostSubscribe /> 83 99 <PostPrevNextButtons 84 100 showPrevNext={preferences.showPrevNext !== false && !isSubpage} ··· 96 112 /> 97 113 {!hasPageBackground && <div className={`spacer h-8 w-full`} />} 98 114 </PageWrapper> 115 + <PublishedFootnotePopover footnotes={footnotes} /> 99 116 </> 100 117 ); 101 118 }
+10
app/lish/[did]/[publication]/[rkey]/PostContent.tsx
··· 46 46 pageId, 47 47 pages, 48 48 pollData, 49 + footnoteIndexMap, 49 50 }: { 50 51 blocks: PubLeafletPagesLinearDocument.Block[]; 51 52 pageId?: string; ··· 56 57 bskyPostData: AppBskyFeedDefs.PostView[]; 57 58 pollData: PollData[]; 58 59 pages: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[]; 60 + footnoteIndexMap?: Map<string, number>; 59 61 }) { 60 62 return ( 61 63 <div ··· 76 78 preview={preview} 77 79 prerenderedCodeBlocks={prerenderedCodeBlocks} 78 80 pollData={pollData} 81 + footnoteIndexMap={footnoteIndexMap} 79 82 /> 80 83 ); 81 84 })} ··· 95 98 pageId, 96 99 pages, 97 100 pollData, 101 + footnoteIndexMap, 98 102 }: { 99 103 pageId?: string; 100 104 preview?: boolean; ··· 107 111 prerenderedCodeBlocks?: Map<string, string>; 108 112 bskyPostData: AppBskyFeedDefs.PostView[]; 109 113 pollData: PollData[]; 114 + footnoteIndexMap?: Map<string, number>; 110 115 }) => { 111 116 let b = block; 112 117 let blockProps = { ··· 362 367 index={index} 363 368 preview={preview} 364 369 pageId={pageId} 370 + footnoteIndexMap={footnoteIndexMap} 365 371 /> 366 372 </blockquote> 367 373 ); ··· 379 385 index={index} 380 386 preview={preview} 381 387 pageId={pageId} 388 + footnoteIndexMap={footnoteIndexMap} 382 389 /> 383 390 </p> 384 391 ); ··· 403 410 index={index} 404 411 preview={preview} 405 412 pageId={pageId} 413 + footnoteIndexMap={footnoteIndexMap} 406 414 /> 407 415 </h2> 408 416 ); ··· 414 422 index={index} 415 423 preview={preview} 416 424 pageId={pageId} 425 + footnoteIndexMap={footnoteIndexMap} 417 426 /> 418 427 </h3> 419 428 ); ··· 426 435 index={index} 427 436 preview={preview} 428 437 pageId={pageId} 438 + footnoteIndexMap={footnoteIndexMap} 429 439 /> 430 440 </h6> 431 441 );
+13 -2
app/lish/[did]/[publication]/[rkey]/PostPages.tsx
··· 185 185 pageId?: string; 186 186 pageOptions?: React.ReactNode; 187 187 allPages: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[]; 188 + hasContentToRight?: boolean; 188 189 }; 189 190 190 191 // Component that renders either Canvas or Linear page based on page type ··· 286 287 <> 287 288 {!sharedProps.fullPageScroll && <BookendSpacer />} 288 289 289 - <PageRenderer page={firstPage} {...sharedProps} /> 290 + <PageRenderer 291 + page={firstPage} 292 + {...sharedProps} 293 + hasContentToRight={ 294 + openPageIds.length > 0 || !!(drawer && !drawer.pageId) 295 + } 296 + /> 290 297 291 298 {drawer && !drawer.pageId && ( 292 299 <InteractionDrawer ··· 306 313 /> 307 314 )} 308 315 309 - {openPageIds.map((openPage) => { 316 + {openPageIds.map((openPage, openPageIndex) => { 310 317 const pageKey = getPageKey(openPage); 311 318 312 319 // Handle thread pages ··· 372 379 {...sharedProps} 373 380 fullPageScroll={false} 374 381 pageId={page.id} 382 + hasContentToRight={ 383 + openPageIndex < openPageIds.length - 1 || 384 + !!(drawer && drawer.pageId === page.id) 385 + } 375 386 pageOptions={ 376 387 <PageOptions 377 388 onClick={() => closePage(openPage)}
+1 -1
app/lish/[did]/[publication]/[rkey]/StaticPostContent.tsx
··· 29 29 did: string; 30 30 }) { 31 31 return ( 32 - <div className="postContent flex flex-col"> 32 + <div className="postContent footnote-scope flex flex-col"> 33 33 {blocks.map((b, index) => { 34 34 return <Block block={b} did={did} key={index} />; 35 35 })}
+15
components/Blocks/TextBlock/RenderYJSFragment.tsx
··· 91 91 ); 92 92 } 93 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 + 94 109 // Handle atMention inline nodes 95 110 if ( 96 111 node.constructor === XmlElement &&
+14
components/Blocks/TextBlock/index.tsx
··· 25 25 import { DotLoader } from "components/utils/DotLoader"; 26 26 import { useMountProsemirror } from "./mountProsemirror"; 27 27 import { schema } from "./schema"; 28 + import { useFootnotePopoverStore } from "components/Footnotes/FootnotePopover"; 28 29 import { blockTextSize } from "src/utils/blockTextSize"; 29 30 30 31 import { Mention, MentionAutocomplete } from "components/Mention"; ··· 170 171 style={{ 171 172 wordBreak: "break-word", 172 173 ...(props.type === "heading" ? { fontSize: headingFontSize[headingLevel?.data.value || 1] } : {}), 174 + }} 175 + onClick={(e) => { 176 + let target = e.target as HTMLElement; 177 + let footnoteRef = target.closest(".footnote-ref") as HTMLElement | null; 178 + if (!footnoteRef) return; 179 + let footnoteID = footnoteRef.dataset.footnoteId; 180 + if (!footnoteID) return; 181 + let store = useFootnotePopoverStore.getState(); 182 + if (store.activeFootnoteID === footnoteID) { 183 + store.close(); 184 + } else { 185 + store.open(footnoteID, footnoteRef); 186 + } 173 187 }} 174 188 className={` 175 189 ${alignmentClass}
+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 + import { insertFootnote } from "./insertFootnote"; 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 + 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); 227 245 return tr; 228 246 }), 229 247
+43
components/Blocks/TextBlock/insertFootnote.ts
··· 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 + }
+111 -27
components/Blocks/TextBlock/mountProsemirror.ts
··· 1 1 import { useLayoutEffect, useRef, useEffect, useState } from "react"; 2 - import { EditorState } from "prosemirror-state"; 2 + import { EditorState, Transaction } from "prosemirror-state"; 3 3 import { EditorView } from "prosemirror-view"; 4 4 import { baseKeymap } from "prosemirror-commands"; 5 5 import { keymap } from "prosemirror-keymap"; ··· 10 10 import { produce } from "immer"; 11 11 12 12 import { schema } from "./schema"; 13 + import { UndoManager } from "src/undoManager"; 13 14 import { TextBlockKeymap } from "./keymap"; 14 15 import { inputrules } from "./inputRules"; 15 16 import { highlightSelectionPlugin } from "./plugins"; ··· 24 25 import { BlockProps } from "../Block"; 25 26 import { useEntitySetContext } from "components/EntitySetProvider"; 26 27 import { didToBlueskyUrl, atUriToUrl } from "src/utils/mentionUtils"; 28 + import { useFootnotePopoverStore } from "components/Footnotes/FootnotePopover"; 27 29 28 30 export function useMountProsemirror({ 29 31 props, ··· 81 83 handleClickOn: (_view, _pos, node, _nodePos, _event, direct) => { 82 84 if (!direct) return; 83 85 86 + // Check for footnote inline nodes 87 + if (node?.type === schema.nodes.footnote) { 88 + let footnoteID = node.attrs.footnoteEntityID; 89 + let supEl = _event.target as HTMLElement; 90 + let sup = supEl.closest(".footnote-ref") as HTMLElement | null; 91 + if (!sup) return; 92 + 93 + // On mobile/tablet or canvas, show popover 94 + let isDesktop = window.matchMedia("(min-width: 1280px)").matches; 95 + let isCanvas = propsRef.current.pageType === "canvas"; 96 + if (!isDesktop || isCanvas) { 97 + let store = useFootnotePopoverStore.getState(); 98 + if (store.activeFootnoteID === footnoteID) { 99 + store.close(); 100 + } else { 101 + store.open(footnoteID, sup); 102 + } 103 + return; 104 + } 105 + 106 + // On desktop, prefer the side column editor if visible 107 + let sideColumn = document.querySelector(".footnote-side-column"); 108 + let editor = sideColumn?.querySelector( 109 + `[data-footnote-editor="${footnoteID}"]`, 110 + ) as HTMLElement | null; 111 + // Fall back to the bottom section 112 + if (!editor) { 113 + editor = document.querySelector( 114 + `[data-footnote-editor="${footnoteID}"]`, 115 + ) as HTMLElement | null; 116 + } 117 + if (editor) { 118 + editor.scrollIntoView({ behavior: "smooth", block: "nearest" }); 119 + let pm = editor.querySelector( 120 + ".ProseMirror", 121 + ) as HTMLElement | null; 122 + if (pm) { 123 + setTimeout(() => pm!.focus(), 100); 124 + } 125 + } 126 + return; 127 + } 128 + 84 129 // Check for didMention inline nodes 85 130 if (node?.type === schema.nodes.didMention) { 86 131 window.open( ··· 143 188 useEditorStates.setState((s) => { 144 189 let oldEditorState = this.state; 145 190 let newState = this.state.apply(tr); 146 - let addToHistory = tr.getMeta("addToHistory"); 147 - let isBulkOp = tr.getMeta("bulkOp"); 148 191 let docHasChanges = tr.steps.length !== 0 || tr.docChanged; 149 192 150 - // Handle undo/redo history with timeout-based grouping 151 - if (addToHistory !== false && docHasChanges) { 152 - if (actionTimeout.current) window.clearTimeout(actionTimeout.current); 153 - else if (!isBulkOp) rep.undoManager.startGroup(); 154 - 155 - if (!isBulkOp) { 156 - actionTimeout.current = window.setTimeout(() => { 157 - rep.undoManager.endGroup(); 158 - actionTimeout.current = null; 159 - }, 200); 193 + // Diff for removed/added footnote nodes 194 + if (docHasChanges) { 195 + let oldFootnotes = new Set<string>(); 196 + let newFootnotes = new Set<string>(); 197 + oldEditorState.doc.descendants((n) => { 198 + if (n.type.name === "footnote") 199 + oldFootnotes.add(n.attrs.footnoteEntityID); 200 + }); 201 + newState.doc.descendants((n) => { 202 + if (n.type.name === "footnote") 203 + newFootnotes.add(n.attrs.footnoteEntityID); 204 + }); 205 + // Removed footnotes 206 + for (let id of oldFootnotes) { 207 + if (!newFootnotes.has(id)) { 208 + repRef.current?.mutate.deleteFootnote({ 209 + footnoteEntityID: id, 210 + blockID: entityID, 211 + }); 212 + } 160 213 } 214 + } 161 215 162 - let setState = (s: EditorState) => () => 163 - useEditorStates.setState( 164 - produce((draft) => { 165 - let view = draft.editorStates[entityID]?.view; 166 - if (!view?.hasFocus() && !isBulkOp) view?.focus(); 167 - draft.editorStates[entityID]!.editor = s; 168 - }), 169 - ); 216 + // Handle undo/redo history with timeout-based grouping 217 + let isBulkOp = tr.getMeta("bulkOp"); 218 + let setState = (s: EditorState) => () => 219 + useEditorStates.setState( 220 + produce((draft) => { 221 + let view = draft.editorStates[entityID]?.view; 222 + if (!view?.hasFocus() && !isBulkOp) view?.focus(); 223 + draft.editorStates[entityID]!.editor = s; 224 + }), 225 + ); 170 226 171 - rep.undoManager.add({ 172 - redo: setState(newState), 173 - undo: setState(oldEditorState), 174 - }); 175 - } 227 + trackUndoRedo( 228 + tr, 229 + rep.undoManager, 230 + actionTimeout, 231 + setState(oldEditorState), 232 + setState(newState), 233 + ); 176 234 177 235 return { 178 236 editorStates: { ··· 191 249 return { mountRef, actionTimeout }; 192 250 } 193 251 194 - function useYJSValue(entityID: string) { 252 + export function trackUndoRedo( 253 + tr: Transaction, 254 + undoManager: UndoManager, 255 + actionTimeout: { current: number | null }, 256 + undo: () => void, 257 + redo: () => void, 258 + ) { 259 + let addToHistory = tr.getMeta("addToHistory"); 260 + let isBulkOp = tr.getMeta("bulkOp"); 261 + let docHasChanges = tr.steps.length !== 0 || tr.docChanged; 262 + 263 + if (addToHistory !== false && docHasChanges) { 264 + if (actionTimeout.current) window.clearTimeout(actionTimeout.current); 265 + else if (!isBulkOp) undoManager.startGroup(); 266 + 267 + if (!isBulkOp) { 268 + actionTimeout.current = window.setTimeout(() => { 269 + undoManager.endGroup(); 270 + actionTimeout.current = null; 271 + }, 200); 272 + } 273 + 274 + undoManager.add({ undo, redo }); 275 + } 276 + } 277 + 278 + export function useYJSValue(entityID: string) { 195 279 const [ydoc] = useState(new Y.Doc()); 196 280 const docStateFromReplicache = useEntity(entityID, "block/text"); 197 281 let rep = useReplicache();
+35
components/Blocks/TextBlock/schema.ts
··· 194 194 ]; 195 195 }, 196 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, 197 232 didMention: { 198 233 attrs: { 199 234 did: {},
+16 -3
components/Blocks/index.tsx
··· 14 14 import { v7 } from "uuid"; 15 15 16 16 import { Block } from "./Block"; 17 - import { useEffect } from "react"; 17 + import { useEffect, useState } from "react"; 18 18 import { addShortcut } from "src/shortcuts"; 19 19 import { useHandleDrop } from "./useHandleDrop"; 20 + import { useFootnoteContext } from "components/Footnotes/FootnoteContext"; 20 21 21 22 export function Blocks(props: { entityID: string }) { 22 23 let rep = useReplicache(); ··· 93 94 ), 94 95 ); 95 96 97 + let { footnotes } = useFootnoteContext(); 98 + 99 + let [areFootnotes, setAreFootnotes] = useState(false); 100 + 101 + useEffect(() => { 102 + setAreFootnotes(footnotes.length > 0); 103 + }, [footnotes.length]); 104 + 96 105 return ( 97 106 <div 98 - className={`blocks w-full flex flex-col outline-hidden h-fit min-h-full`} 107 + className={`blocks w-full flex flex-col outline-hidden ${areFootnotes ? "h-fit" : "min-h-full"}`} 99 108 onClick={async (e) => { 100 109 if (!permissions.write) return; 101 110 if (useUIState.getState().selectedBlocks.length > 1) return; ··· 167 176 lastVisibleBlock={lastVisibleBlock || undefined} 168 177 lastRootBlock={lastRootBlock || undefined} 169 178 entityID={props.entityID} 179 + areFootnotes={areFootnotes} 170 180 /> 171 181 </div> 172 182 ); ··· 226 236 lastRootBlock: Block | undefined; 227 237 lastVisibleBlock: Block | undefined; 228 238 entityID: string; 239 + areFootnotes: boolean; 229 240 }) => { 230 241 let { rep } = useReplicache(); 231 242 let entity_set = useEntitySetContext(); ··· 236 247 }); 237 248 238 249 if (!entity_set.permissions.write) return; 250 + if (props.areFootnotes) return; 251 + 239 252 return ( 240 253 <div 241 - className="blockListClickableBottomArea shrink-0 h-[50vh]" 254 + className="blockListClickableBottomArea grow shrink-0 h-[50vh]" 242 255 onClick={() => { 243 256 let newEntityID = v7(); 244 257 if (
+15
components/DesktopFooter.tsx
··· 2 2 import { useUIState } from "src/useUIState"; 3 3 import { Media } from "./Media"; 4 4 import { Toolbar } from "./Toolbar"; 5 + import { FootnoteToolbar } from "./Toolbar/FootnoteToolbarWrapper"; 5 6 import { useEntitySetContext } from "./EntitySetProvider"; 6 7 import { focusBlock } from "src/utils/focusBlock"; 7 8 import { hasBlockToolbar } from "app/[leaflet_id]/Footer"; ··· 17 18 18 19 let blockType = useEntity(focusedEntity?.entityID || null, "block/type")?.data 19 20 .value; 21 + 22 + let isFootnoteFocused = 23 + focusedEntity?.entityType === "footnote" && 24 + focusedEntity.parent === props.pageID; 20 25 21 26 return ( 22 27 <Media ··· 41 46 /> 42 47 </div> 43 48 )} 49 + {isFootnoteFocused && entity_set.permissions.write && ( 50 + <div 51 + className="pointer-events-auto w-fit mx-auto py-1 px-3 h-9 bg-bg-page border border-border rounded-full shadow-sm" 52 + onMouseDown={(e) => { 53 + if (e.currentTarget === e.target) e.preventDefault(); 54 + }} 55 + > 56 + <FootnoteToolbar pageID={props.pageID} /> 57 + </div> 58 + )} 44 59 </Media> 45 60 ); 46 61 }
+18
components/Footnotes/FootnoteContext.tsx
··· 1 + import { createContext, useContext } from "react"; 2 + import type { FootnoteInfo } from "./usePageFootnotes"; 3 + 4 + type FootnoteContextValue = { 5 + pageID: string; 6 + footnotes: FootnoteInfo[]; 7 + indexMap: Record<string, number>; 8 + }; 9 + 10 + export const FootnoteContext = createContext<FootnoteContextValue>({ 11 + pageID: "", 12 + footnotes: [], 13 + indexMap: {}, 14 + }); 15 + 16 + export function useFootnoteContext() { 17 + return useContext(FootnoteContext); 18 + }
+235
components/Footnotes/FootnoteEditor.tsx
··· 1 + import { useLayoutEffect, useRef } from "react"; 2 + import { EditorState, TextSelection } 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 { schema } from "components/Blocks/TextBlock/schema"; 8 + import { useReplicache } from "src/replicache"; 9 + import { autolink } from "components/Blocks/TextBlock/autolink-plugin"; 10 + import { betterIsUrl } from "src/utils/isURL"; 11 + import { 12 + useYJSValue, 13 + trackUndoRedo, 14 + } from "components/Blocks/TextBlock/mountProsemirror"; 15 + import { DeleteTiny } from "components/Icons/DeleteTiny"; 16 + import { FootnoteItemLayout } from "./FootnoteItemLayout"; 17 + import { useEditorStates } from "src/state/useEditorState"; 18 + import { useUIState } from "src/useUIState"; 19 + import { useFootnoteContext } from "./FootnoteContext"; 20 + 21 + export function FootnoteEditor(props: { 22 + footnoteEntityID: string; 23 + index: number; 24 + editable: boolean; 25 + onDelete?: () => void; 26 + autoFocus?: boolean; 27 + }) { 28 + let mountRef = useRef<HTMLDivElement | null>(null); 29 + let rep = useReplicache(); 30 + let value = useYJSValue(props.footnoteEntityID); 31 + let actionTimeout = useRef<number | null>(null); 32 + let { pageID } = useFootnoteContext(); 33 + 34 + useLayoutEffect(() => { 35 + if (!mountRef.current || !value) return; 36 + 37 + let plugins = [ 38 + ySyncPlugin(value), 39 + keymap({ 40 + "Meta-b": toggleMark(schema.marks.strong), 41 + "Ctrl-b": toggleMark(schema.marks.strong), 42 + "Meta-u": toggleMark(schema.marks.underline), 43 + "Ctrl-u": toggleMark(schema.marks.underline), 44 + "Meta-i": toggleMark(schema.marks.em), 45 + "Ctrl-i": toggleMark(schema.marks.em), 46 + "Shift-Enter": (state, dispatch) => { 47 + let hardBreak = schema.nodes.hard_break.create(); 48 + if (dispatch) { 49 + dispatch(state.tr.replaceSelectionWith(hardBreak).scrollIntoView()); 50 + } 51 + return true; 52 + }, 53 + Enter: (_state, _dispatch, view) => { 54 + view?.dom.blur(); 55 + return true; 56 + }, 57 + }), 58 + keymap(baseKeymap), 59 + autolink({ 60 + type: schema.marks.link, 61 + shouldAutoLink: () => true, 62 + defaultProtocol: "https", 63 + }), 64 + ]; 65 + 66 + let state = EditorState.create({ schema, plugins }); 67 + let view = new EditorView( 68 + { mount: mountRef.current }, 69 + { 70 + state, 71 + editable: () => props.editable, 72 + handlePaste: (view, e) => { 73 + let text = e.clipboardData?.getData("text"); 74 + if (text && betterIsUrl(text)) { 75 + let selection = view.state.selection as TextSelection; 76 + let tr = view.state.tr; 77 + let { from, to } = selection; 78 + if (selection.empty) { 79 + tr.insertText(text, selection.from); 80 + tr.addMark( 81 + from, 82 + from + text.length, 83 + schema.marks.link.create({ href: text }), 84 + ); 85 + } else { 86 + tr.addMark(from, to, schema.marks.link.create({ href: text })); 87 + } 88 + view.dispatch(tr); 89 + return true; 90 + } 91 + }, 92 + handleClickOn: (_view, _pos, node, _nodePos, _event, direct) => { 93 + if (!direct) return; 94 + if (node.nodeSize - 2 <= _pos) return; 95 + const nodeAt1 = node.nodeAt(_pos - 1); 96 + const nodeAt2 = node.nodeAt(Math.max(_pos - 2, 0)); 97 + let linkMark = 98 + nodeAt1?.marks.find((f) => f.type === schema.marks.link) || 99 + nodeAt2?.marks.find((f) => f.type === schema.marks.link); 100 + if (linkMark) { 101 + window.open(linkMark.attrs.href, "_blank"); 102 + return; 103 + } 104 + }, 105 + dispatchTransaction(this: EditorView, tr) { 106 + let oldState = this.state; 107 + let newState = this.state.apply(tr); 108 + this.updateState(newState); 109 + 110 + useEditorStates.setState((s) => ({ 111 + editorStates: { 112 + ...s.editorStates, 113 + [props.footnoteEntityID]: { 114 + editor: newState, 115 + view: this, 116 + }, 117 + }, 118 + })); 119 + 120 + trackUndoRedo( 121 + tr, 122 + rep.undoManager, 123 + actionTimeout, 124 + () => { 125 + this.focus(); 126 + this.updateState(oldState); 127 + }, 128 + () => { 129 + this.focus(); 130 + this.updateState(newState); 131 + }, 132 + ); 133 + }, 134 + }, 135 + ); 136 + 137 + // Register editor state 138 + useEditorStates.setState((s) => ({ 139 + editorStates: { 140 + ...s.editorStates, 141 + [props.footnoteEntityID]: { 142 + editor: view.state, 143 + view, 144 + }, 145 + }, 146 + })); 147 + 148 + // Subscribe to external state changes (e.g. link toolbar) 149 + let unsubscribe = useEditorStates.subscribe((s) => { 150 + let editorState = s.editorStates[props.footnoteEntityID]; 151 + if (editorState?.editor) 152 + editorState.view?.updateState(editorState.editor); 153 + }); 154 + 155 + // Set focusedEntity on focus 156 + let handleFocus = () => { 157 + useUIState.setState({ 158 + focusedEntity: { 159 + entityType: "footnote", 160 + entityID: props.footnoteEntityID, 161 + parent: pageID, 162 + }, 163 + }); 164 + }; 165 + view.dom.addEventListener("focus", handleFocus); 166 + 167 + if (props.autoFocus) { 168 + setTimeout(() => view.focus(), 50); 169 + } 170 + 171 + return () => { 172 + unsubscribe(); 173 + view.dom.removeEventListener("focus", handleFocus); 174 + view.destroy(); 175 + useEditorStates.setState((s) => { 176 + let { [props.footnoteEntityID]: _, ...rest } = s.editorStates; 177 + return { editorStates: rest }; 178 + }); 179 + }; 180 + }, [ 181 + props.footnoteEntityID, 182 + value, 183 + props.editable, 184 + props.autoFocus, 185 + rep.undoManager, 186 + pageID, 187 + ]); 188 + 189 + return ( 190 + <div data-footnote-editor={props.footnoteEntityID}> 191 + <FootnoteItemLayout 192 + index={props.index} 193 + indexAction={() => { 194 + let pm = mountRef.current?.querySelector( 195 + ".ProseMirror", 196 + ) as HTMLElement | null; 197 + if (pm) { 198 + pm.focus(); 199 + } 200 + }} 201 + trailing={ 202 + props.editable && props.onDelete ? ( 203 + <FootnoteDeleteButton 204 + footnoteEntityID={props.footnoteEntityID} 205 + onDelete={props.onDelete} 206 + /> 207 + ) : undefined 208 + } 209 + > 210 + <div ref={mountRef} className="outline-hidden" /> 211 + </FootnoteItemLayout> 212 + </div> 213 + ); 214 + } 215 + 216 + function FootnoteDeleteButton(props: { 217 + footnoteEntityID: string; 218 + onDelete: () => void; 219 + }) { 220 + let isActive = useUIState( 221 + (s) => 222 + s.focusedEntity?.entityType === "footnote" && 223 + s.focusedEntity.entityID === props.footnoteEntityID, 224 + ); 225 + 226 + return ( 227 + <button 228 + className={`shrink-0 mt-0.5 text-tertiary hover:text-accent-contrast transition-opacity ${isActive ? "opacity-100" : "opacity-0"}`} 229 + onClick={props.onDelete} 230 + title="Delete footnote" 231 + > 232 + <DeleteTiny /> 233 + </button> 234 + ); 235 + }
+58
components/Footnotes/FootnoteItemLayout.tsx
··· 1 + import { ReactNode } from "react"; 2 + 3 + export function FootnoteItemLayout(props: { 4 + index: number; 5 + indexAction?: () => void; 6 + indexHref?: string; 7 + children: ReactNode; 8 + trailing?: ReactNode; 9 + id?: string; 10 + className?: string; 11 + }) { 12 + let indexClassName = 13 + "text-tertiary font-medium shrink-0 text-sm leading-normal no-underline hover:underline cursor-pointer"; 14 + 15 + let indexContent = <>{props.index}.</>; 16 + 17 + return ( 18 + <div 19 + id={props.id} 20 + className={`footnote-item flex items-start gap-2 text-sm group/footnote ${props.className ?? ""}`} 21 + > 22 + {props.indexHref ? ( 23 + <a href={props.indexHref} className={indexClassName}> 24 + {indexContent} 25 + </a> 26 + ) : ( 27 + <button 28 + className={indexClassName} 29 + onClick={props.indexAction} 30 + title="Jump to footnote in text" 31 + > 32 + {indexContent} 33 + </button> 34 + )} 35 + <div 36 + className="grow min-w-0 text-secondary [&_.ProseMirror]:outline-hidden" 37 + style={{ wordBreak: "break-word" }} 38 + > 39 + {props.children} 40 + </div> 41 + {props.trailing} 42 + </div> 43 + ); 44 + } 45 + 46 + export function FootnoteSectionLayout(props: { 47 + children: ReactNode; 48 + className?: string; 49 + }) { 50 + return ( 51 + <div 52 + className={`footnote-section px-3 sm:px-4 pb-2 ${props.className ?? ""}`} 53 + > 54 + <hr className="border-border-light mb-3" /> 55 + <div className="flex flex-col gap-2">{props.children}</div> 56 + </div> 57 + ); 58 + }
+152
components/Footnotes/FootnotePopover.tsx
··· 1 + "use client"; 2 + 3 + import { useEffect, useRef, useState, useCallback, useMemo } 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<{ 33 + top: number; 34 + left: number; 35 + arrowLeft: number; 36 + } | null>(null); 37 + 38 + let footnote = footnotes.find( 39 + (fn) => fn.footnoteEntityID === activeFootnoteID, 40 + ); 41 + 42 + // Compute the displayed index from DOM order (matching CSS counters) 43 + // rather than the data model order, which may differ if footnotes 44 + // were inserted out of order within a block. 45 + let displayIndex = useMemo(() => { 46 + if (!anchorElement || !footnote) return footnote?.index ?? 0; 47 + let container = anchorElement.closest('.footnote-scope'); 48 + if (!container) return footnote.index; 49 + let allRefs = Array.from(container.querySelectorAll(".footnote-ref")); 50 + let pos = allRefs.indexOf(anchorElement); 51 + return pos >= 0 ? pos + 1 : footnote.index; 52 + }, [anchorElement, footnote]); 53 + 54 + let updatePosition = useCallback(() => { 55 + if (!anchorElement || !popoverRef.current) return; 56 + 57 + let anchorRect = anchorElement.getBoundingClientRect(); 58 + let popoverWidth = popoverRef.current.offsetWidth; 59 + let popoverHeight = popoverRef.current.offsetHeight; 60 + 61 + // Position above the anchor by default, fall back to below 62 + let top = anchorRect.top - popoverHeight - 8; 63 + let left = anchorRect.left + anchorRect.width / 2 - popoverWidth / 2; 64 + 65 + // Clamp horizontal position 66 + let padding = 12; 67 + left = Math.max( 68 + padding, 69 + Math.min(left, window.innerWidth - popoverWidth - padding), 70 + ); 71 + 72 + // Arrow position relative to popover 73 + let arrowLeft = anchorRect.left + anchorRect.width / 2 - left; 74 + 75 + // If not enough room above, show below 76 + if (top < padding) { 77 + top = anchorRect.bottom + 8; 78 + } 79 + 80 + setPosition({ top, left, arrowLeft }); 81 + }, [anchorElement]); 82 + 83 + useEffect(() => { 84 + if (!activeFootnoteID || !anchorElement) { 85 + setPosition(null); 86 + return; 87 + } 88 + 89 + // Delay to let the popover render so we can measure it 90 + requestAnimationFrame(updatePosition); 91 + 92 + let handleClickOutside = (e: Event) => { 93 + let target = e.target as Node; 94 + if ( 95 + popoverRef.current && 96 + !popoverRef.current.contains(target) && 97 + !anchorElement.contains(target) 98 + ) { 99 + close(); 100 + } 101 + }; 102 + 103 + let handleScroll = () => close(); 104 + 105 + document.addEventListener("mousedown", handleClickOutside); 106 + document.addEventListener("touchstart", handleClickOutside); 107 + // Close on scroll of any scroll container 108 + let scrollWrapper = anchorElement.closest(".pageScrollWrapper"); 109 + scrollWrapper?.addEventListener("scroll", handleScroll); 110 + window.addEventListener("resize", close); 111 + 112 + return () => { 113 + document.removeEventListener("mousedown", handleClickOutside); 114 + document.removeEventListener("touchstart", handleClickOutside); 115 + scrollWrapper?.removeEventListener("scroll", handleScroll); 116 + window.removeEventListener("resize", close); 117 + }; 118 + }, [activeFootnoteID, anchorElement, close, updatePosition]); 119 + 120 + if (!activeFootnoteID || !footnote) return null; 121 + 122 + return ( 123 + <div 124 + ref={popoverRef} 125 + className="footnote-popover fixed z-50 bg-bg-page border border-border rounded-lg shadow-md px-3 py-2 w-[min(calc(100vw-24px),320px)]" 126 + style={{ 127 + top: position?.top ?? -9999, 128 + left: position?.left ?? -9999, 129 + visibility: position ? "visible" : "hidden", 130 + }} 131 + > 132 + <FootnoteEditor 133 + footnoteEntityID={footnote.footnoteEntityID} 134 + index={displayIndex} 135 + editable={permissions.write} 136 + autoFocus={permissions.write} 137 + onDelete={ 138 + permissions.write 139 + ? () => { 140 + deleteFootnoteFromBlock( 141 + footnote.footnoteEntityID, 142 + footnote.blockID, 143 + rep.rep, 144 + ); 145 + close(); 146 + } 147 + : undefined 148 + } 149 + /> 150 + </div> 151 + ); 152 + }
+38
components/Footnotes/FootnoteSection.tsx
··· 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 + import { FootnoteSectionLayout } from "./FootnoteItemLayout"; 7 + 8 + export function FootnoteSection(props: { hiddenOnDesktop?: boolean }) { 9 + let { footnotes } = useFootnoteContext(); 10 + let { permissions } = useEntitySetContext(); 11 + let rep = useReplicache(); 12 + 13 + if (footnotes.length === 0) return null; 14 + 15 + return ( 16 + <FootnoteSectionLayout className={props.hiddenOnDesktop ? "lg:hidden" : ""}> 17 + {footnotes.map((fn) => ( 18 + <FootnoteEditor 19 + key={fn.footnoteEntityID} 20 + footnoteEntityID={fn.footnoteEntityID} 21 + index={fn.index} 22 + editable={permissions.write} 23 + onDelete={ 24 + permissions.write 25 + ? () => { 26 + deleteFootnoteFromBlock( 27 + fn.footnoteEntityID, 28 + fn.blockID, 29 + rep.rep, 30 + ); 31 + } 32 + : undefined 33 + } 34 + /> 35 + ))} 36 + </FootnoteSectionLayout> 37 + ); 38 + }
+63
components/Footnotes/FootnoteSideColumn.tsx
··· 1 + import { 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 + import { FootnoteSideColumnLayout } from "./FootnoteSideColumnLayout"; 8 + 9 + type EditorFootnoteItem = { 10 + id: string; 11 + index: number; 12 + footnoteEntityID: string; 13 + blockID: string; 14 + }; 15 + 16 + export function FootnoteSideColumn(props: { 17 + pageEntityID: string; 18 + visible: boolean; 19 + fullPageScroll?: boolean; 20 + }) { 21 + let { footnotes } = useFootnoteContext(); 22 + let { permissions } = useEntitySetContext(); 23 + let rep = useReplicache(); 24 + 25 + let items: EditorFootnoteItem[] = footnotes.map((fn) => ({ 26 + id: fn.footnoteEntityID, 27 + index: fn.index, 28 + footnoteEntityID: fn.footnoteEntityID, 29 + blockID: fn.blockID, 30 + })); 31 + 32 + let getAnchorSelector = useCallback( 33 + (item: EditorFootnoteItem) => 34 + `.footnote-ref[data-footnote-id="${item.id}"]`, 35 + [], 36 + ); 37 + 38 + let renderItem = useCallback( 39 + (item: EditorFootnoteItem & { top: number }) => ( 40 + <FootnoteEditor 41 + footnoteEntityID={item.footnoteEntityID} 42 + index={item.index} 43 + editable={permissions.write} 44 + onDelete={ 45 + permissions.write 46 + ? () => deleteFootnoteFromBlock(item.footnoteEntityID, item.blockID, rep.rep) 47 + : undefined 48 + } 49 + /> 50 + ), 51 + [permissions.write, rep.rep], 52 + ); 53 + 54 + return ( 55 + <FootnoteSideColumnLayout 56 + items={items} 57 + visible={props.visible} 58 + fullPageScroll={props.fullPageScroll} 59 + getAnchorSelector={getAnchorSelector} 60 + renderItem={renderItem} 61 + /> 62 + ); 63 + }
+178
components/Footnotes/FootnoteSideColumnLayout.tsx
··· 1 + "use client"; 2 + 3 + import { useEffect, useRef, useState, useCallback, ReactNode } from "react"; 4 + import { useUIState } from "src/useUIState"; 5 + 6 + export type FootnoteSideItem = { 7 + id: string; 8 + index: number; 9 + }; 10 + 11 + const GAP = 4; 12 + 13 + export function FootnoteSideColumnLayout<T extends FootnoteSideItem>(props: { 14 + items: T[]; 15 + visible: boolean; 16 + fullPageScroll?: boolean; 17 + getAnchorSelector: (item: T) => string; 18 + renderItem: (item: T & { top: number }) => ReactNode; 19 + }) { 20 + let containerRef = useRef<HTMLDivElement>(null); 21 + let innerRef = useRef<HTMLDivElement>(null); 22 + let [positions, setPositions] = useState<(T & { top: number })[]>([]); 23 + let [scrollOffset, setScrollOffset] = useState(0); 24 + 25 + let calculatePositions = useCallback(() => { 26 + let container = containerRef.current; 27 + let inner = innerRef.current; 28 + if (!container || !inner || props.items.length === 0) { 29 + setPositions([]); 30 + return; 31 + } 32 + 33 + let scrollWrapper = container.closest(".pageWrapper") 34 + ?.querySelector(".pageScrollWrapper") as HTMLElement | null; 35 + if (!scrollWrapper) return; 36 + 37 + let scrollTop = scrollWrapper.scrollTop; 38 + let scrollWrapperRect = scrollWrapper.getBoundingClientRect(); 39 + setScrollOffset(scrollTop); 40 + 41 + let measurements: (T & { anchorTop: number; height: number })[] = []; 42 + 43 + for (let item of props.items) { 44 + let supEl = scrollWrapper.querySelector( 45 + props.getAnchorSelector(item), 46 + ) as HTMLElement | null; 47 + if (!supEl) continue; 48 + 49 + let supRect = supEl.getBoundingClientRect(); 50 + let anchorTop = supRect.top - scrollWrapperRect.top + scrollTop; 51 + 52 + let itemEl = inner.querySelector( 53 + `[data-footnote-side-id="${item.id}"]`, 54 + ) as HTMLElement | null; 55 + let height = itemEl ? itemEl.offsetHeight : 54; 56 + 57 + measurements.push({ ...item, anchorTop, height }); 58 + } 59 + 60 + let resolved: (T & { top: number })[] = []; 61 + let nextAvailableTop = 0; 62 + for (let m of measurements) { 63 + let top = Math.max(m.anchorTop, nextAvailableTop); 64 + resolved.push({ 65 + ...m, 66 + top, 67 + }); 68 + nextAvailableTop = top + m.height + GAP; 69 + } 70 + 71 + setPositions(resolved); 72 + }, [props.items, props.getAnchorSelector]); 73 + 74 + useEffect(() => { 75 + if (!props.visible) return; 76 + calculatePositions(); 77 + 78 + let scrollWrapper = containerRef.current?.closest(".pageWrapper") 79 + ?.querySelector(".pageScrollWrapper") as HTMLElement | null; 80 + if (!scrollWrapper) return; 81 + 82 + let onScroll = () => { 83 + setScrollOffset(scrollWrapper!.scrollTop); 84 + }; 85 + 86 + scrollWrapper.addEventListener("scroll", onScroll); 87 + 88 + let resizeObserver = new ResizeObserver(calculatePositions); 89 + resizeObserver.observe(scrollWrapper); 90 + 91 + let mutationObserver = new MutationObserver(calculatePositions); 92 + mutationObserver.observe(scrollWrapper, { 93 + childList: true, 94 + subtree: true, 95 + characterData: true, 96 + }); 97 + 98 + return () => { 99 + scrollWrapper!.removeEventListener("scroll", onScroll); 100 + resizeObserver.disconnect(); 101 + mutationObserver.disconnect(); 102 + }; 103 + }, [props.visible, calculatePositions]); 104 + 105 + if (!props.visible || props.items.length === 0) return null; 106 + 107 + return ( 108 + <div 109 + ref={containerRef} 110 + className={`footnote-side-column hidden lg:block absolute top-0 w-[200px] pointer-events-none ${ 111 + props.fullPageScroll 112 + ? "left-[calc(50%+var(--page-width-units)/2+12px)]" 113 + : "left-full ml-3" 114 + }`} 115 + style={{ height: "100%" }} 116 + > 117 + <div 118 + ref={innerRef} 119 + className="relative pointer-events-auto" 120 + style={{ transform: `translateY(-${scrollOffset}px)` }} 121 + > 122 + {positions.map((item) => ( 123 + <SideItem key={item.id} id={item.id} top={item.top} onResize={calculatePositions}> 124 + {props.renderItem(item)} 125 + </SideItem> 126 + ))} 127 + </div> 128 + </div> 129 + ); 130 + } 131 + 132 + function SideItem(props: { 133 + children: ReactNode; 134 + id: string; 135 + top: number; 136 + onResize: () => void; 137 + }) { 138 + let ref = useRef<HTMLDivElement>(null); 139 + let [overflows, setOverflows] = useState(false); 140 + let isFocused = useUIState( 141 + (s) => 142 + s.focusedEntity?.entityType === "footnote" && 143 + s.focusedEntity.entityID === props.id, 144 + ); 145 + 146 + useEffect(() => { 147 + let el = ref.current; 148 + if (!el) return; 149 + 150 + let check = () => setOverflows(el!.scrollHeight > el!.clientHeight + 1); 151 + check(); 152 + 153 + let ro = new ResizeObserver(() => { 154 + check(); 155 + props.onResize(); 156 + }); 157 + ro.observe(el); 158 + 159 + let mo = new MutationObserver(check); 160 + mo.observe(el, { childList: true, subtree: true, characterData: true }); 161 + 162 + return () => { 163 + ro.disconnect(); 164 + mo.disconnect(); 165 + }; 166 + }, [props.onResize]); 167 + 168 + return ( 169 + <div 170 + ref={ref} 171 + data-footnote-side-id={props.id} 172 + className={`absolute left-0 right-0 text-sm footnote-side-enter footnote-side-item${overflows ? " has-overflow" : ""}${isFocused ? " footnote-side-focused" : ""}`} 173 + style={{ top: props.top }} 174 + > 175 + {props.children} 176 + </div> 177 + ); 178 + }
+32
components/Footnotes/deleteFootnoteFromBlock.ts
··· 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 + }
+61
components/Footnotes/usePageFootnotes.ts
··· 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 cardBlocks = await scan.eav(pageID, "card/block"); 18 + let canvasBlocks = await scan.eav(pageID, "canvas/block"); 19 + 20 + let sortedCardBlocks = cardBlocks 21 + .map((b) => ({ value: b.data.value, position: b.data.position })) 22 + .toSorted((a, b) => (a.position > b.position ? 1 : -1)); 23 + 24 + let sortedCanvasBlocks = canvasBlocks 25 + .map((b) => ({ value: b.data.value, position: b.data.position })) 26 + .toSorted((a, b) => { 27 + if (a.position.y === b.position.y) return a.position.x - b.position.x; 28 + return a.position.y - b.position.y; 29 + }); 30 + 31 + let sorted = [...sortedCardBlocks, ...sortedCanvasBlocks]; 32 + 33 + let footnotes: FootnoteInfo[] = []; 34 + let indexMap: Record<string, number> = {}; 35 + let idx = 1; 36 + 37 + for (let block of sorted) { 38 + let blockFootnotes = await scan.eav(block.value, "block/footnote"); 39 + let sortedFootnotes = blockFootnotes.toSorted((a, b) => 40 + a.data.position > b.data.position ? 1 : -1, 41 + ); 42 + for (let fn of sortedFootnotes) { 43 + footnotes.push({ 44 + footnoteEntityID: fn.data.value, 45 + blockID: block.value, 46 + index: idx, 47 + }); 48 + indexMap[fn.data.value] = idx; 49 + idx++; 50 + } 51 + } 52 + 53 + return { pageID, footnotes, indexMap }; 54 + }, 55 + { dependencies: [pageID] }, 56 + ); 57 + 58 + return ( 59 + data || { pageID, footnotes: [], indexMap: {} as Record<string, number> } 60 + ); 61 + }
+55 -29
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 + 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"; 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 + 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 + 39 52 return ( 40 53 <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); 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 + /> 47 74 } 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} /> 75 + footnoteSideColumn={ 76 + <FootnoteSideColumn 77 + pageEntityID={props.entityID} 78 + visible={sideColumnVisible} 79 + fullPageScroll={props.fullPageScroll} 80 + /> 81 + } 82 + > 83 + {props.first && pageType === "doc" && ( 84 + <> 85 + <PublicationMetadata /> 86 + </> 87 + )} 88 + <PageContent entityID={props.entityID} first={props.first} /> 89 + </PageWrapper> 90 + <DesktopPageFooter pageID={props.entityID} /> 91 + <FootnotePopover /> 92 + </FootnoteContext.Provider> 70 93 </CardThemeProvider> 71 94 ); 72 95 } ··· 75 98 id: string; 76 99 children: React.ReactNode; 77 100 pageOptions?: React.ReactNode; 101 + footnoteSideColumn?: React.ReactNode; 78 102 fullPageScroll: boolean; 79 103 isFocused?: boolean; 80 104 onClickAction?: (e: React.MouseEvent) => void; ··· 123 147 `} 124 148 > 125 149 <div 126 - className={`postPageContent 150 + className={`postPageContent footnote-scope 127 151 ${props.fullPageScroll ? "sm:max-w-[var(--page-width-units)] mx-auto" : "w-full h-full"} 128 152 `} 129 153 > ··· 132 156 </div> 133 157 </div> 134 158 {props.pageOptions} 159 + {props.footnoteSideColumn} 135 160 </div> 136 161 ); 137 162 }; ··· 205 230 /> 206 231 ) : null} 207 232 <Blocks entityID={props.entityID} /> 233 + <FootnoteSection /> 208 234 <div className="h-4 sm:h-6 w-full" /> 209 235 {/* we handle page bg in this sepate div so that 210 236 we can apply an opacity the background image
+57
components/Toolbar/FootnoteButton.tsx
··· 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 + import { ToolbarButton } from "."; 9 + 10 + export function FootnoteButton() { 11 + let rep = useReplicache(); 12 + let entity_set = useEntitySetContext(); 13 + let focusedBlock = useUIState((s) => s.focusedEntity); 14 + 15 + return ( 16 + <ToolbarButton 17 + tooltipContent={"Insert Footnote"} 18 + onClick={async (e) => { 19 + e.preventDefault(); 20 + if (!focusedBlock || focusedBlock.entityType !== "block") return; 21 + let editorState = 22 + useEditorStates.getState().editorStates[focusedBlock.entityID]; 23 + if (!editorState?.view || !rep.rep) return; 24 + await insertFootnote( 25 + editorState.view, 26 + focusedBlock.entityID, 27 + rep.rep, 28 + entity_set.set, 29 + ); 30 + }} 31 + > 32 + <FootnoteIcon /> 33 + </ToolbarButton> 34 + ); 35 + } 36 + 37 + function FootnoteIcon(props: Props) { 38 + return ( 39 + <svg 40 + width="24" 41 + height="24" 42 + viewBox="0 0 24 24" 43 + fill="none" 44 + xmlns="http://www.w3.org/2000/svg" 45 + {...props} 46 + > 47 + <path 48 + d="M21.3926 4.21564C21.503 4.21564 21.5926 4.30518 21.5926 4.41564V10.0352C21.5926 10.1456 21.503 10.2352 21.3926 10.2352H20.1584C20.0479 10.2352 19.9584 10.1456 19.9584 10.0352V5.74816C19.9584 5.73289 19.946 5.72052 19.9307 5.72052C19.9257 5.72052 19.9208 5.72187 19.9165 5.72444L18.909 6.32717C18.7757 6.40692 18.6063 6.31088 18.6063 6.15553V5.23309C18.6063 5.16341 18.6426 5.09876 18.702 5.06243L20.0397 4.24498C20.0711 4.22579 20.1072 4.21564 20.144 4.21564H21.3926Z" 49 + fill="currentColor" 50 + /> 51 + <path 52 + d="M13.6152 5C13.8361 5 14.0156 5.17948 14.0156 5.40039V10.4297C14.254 10.2111 14.5346 9.9596 15.1074 9.66016C15.6803 9.34771 16.1974 9.19924 16.9785 9.19922C17.7797 9.19927 18.356 9.39478 18.958 9.75879V10.0352C18.9581 10.6977 19.4957 11.2352 20.1582 11.2354H20.417C20.4857 11.3518 20.5525 11.4718 20.6133 11.5977C20.9909 12.353 21.1797 13.2326 21.1797 14.2354C21.1797 15.2379 21.1195 16.0091 20.6455 16.9775C20.2754 17.7337 19.8632 18.1974 19.2607 18.6299C18.766 18.9849 17.9146 19.2011 17.0684 19.2012C16.3263 19.2012 15.7474 19.0709 15.2266 18.8105C14.7059 18.5502 14.2825 18.231 13.957 17.8535V18.6299C13.957 18.8478 13.7823 19.026 13.5645 19.0303L12.2158 19.0566C11.992 19.0609 11.8076 18.8802 11.8076 18.6562V5.40039C11.8076 5.17954 11.9872 5.0001 12.208 5H13.6152ZM7.05469 9.16113C8.33062 9.1612 9.28775 9.48019 9.92578 10.1182C10.5637 10.7562 10.8828 11.57 10.8828 12.5596V18.6641C10.8828 18.885 10.7033 19.0645 10.4824 19.0645H9.00586C8.77508 19.0642 8.59172 18.869 8.60645 18.6387L8.65625 17.873C8.34379 18.2896 7.97256 18.6087 7.54297 18.8301C7.11324 19.0514 6.5394 19.1621 5.82324 19.1621C5.14633 19.162 4.56014 19.0514 4.06543 18.8301C3.57077 18.5957 3.18651 18.2766 2.91309 17.873C2.65267 17.4563 2.52246 16.9737 2.52246 16.4268C2.52256 15.6066 2.82203 14.9228 3.4209 14.376C4.03285 13.8161 4.92498 13.4711 6.09668 13.3408L8.65625 13.0674V12.3057C8.65625 11.9932 8.5194 11.7132 8.24609 11.4658C7.97268 11.2055 7.53625 11.0753 6.9375 11.0752C6.41668 11.0752 5.95996 11.2054 5.56934 11.4658C5.30375 11.64 5.0992 11.8786 4.95605 12.1816C4.85494 12.3957 4.61483 12.5278 4.39258 12.4463L3.24316 12.0234C3.04644 11.9511 2.9365 11.7382 3.0127 11.543C3.28354 10.8507 3.73231 10.2976 4.3584 9.88379C5.10064 9.40199 5.99995 9.16116 7.05469 9.16113ZM6.31152 15.0596C5.81698 15.1247 5.44557 15.268 5.19824 15.4893C4.95102 15.6975 4.82725 15.9647 4.82715 16.29C4.82715 16.5895 4.94437 16.8509 5.17871 17.0723C5.42601 17.2805 5.75841 17.3847 6.1748 17.3848C6.70866 17.3848 7.15883 17.2871 7.52344 17.0918C7.90102 16.8965 8.18098 16.6155 8.36328 16.251C8.55847 15.8864 8.65625 15.437 8.65625 14.9033C8.65611 14.8309 8.59244 14.7752 8.52051 14.7842L6.31152 15.0596ZM16.6758 11.1533C15.9644 11.1533 15.6758 11.2705 15.2852 11.5049C14.9076 11.7393 14.6016 12.0844 14.3672 12.54C14.1328 12.9827 14.0156 13.5297 14.0156 14.1807C14.0156 14.8317 14.1329 15.3851 14.3672 15.8408C14.6016 16.2835 14.9076 16.623 15.2852 16.8574C15.6756 17.0917 15.9645 17.209 16.6758 17.209C17.3871 17.2089 17.8897 16.9826 18.3584 16.4229C18.8401 15.863 19.081 15.1337 19.0811 14.2354C19.0811 13.3368 18.8402 12.6068 18.3584 12.0469C17.8897 11.4871 17.387 11.1535 16.6758 11.1533Z" 53 + fill="currentColor" 54 + /> 55 + </svg> 56 + ); 57 + }
+70
components/Toolbar/FootnoteTextToolbar.tsx
··· 1 + import { Separator, ShortcutKey } from "components/Layout"; 2 + import { metaKey } from "src/utils/metaKey"; 3 + import { LinkButton } from "./InlineLinkToolbar"; 4 + import { TextDecorationButton } from "./TextDecorationButton"; 5 + import { schema } from "components/Blocks/TextBlock/schema"; 6 + import { BoldSmall, ItalicSmall, StrikethroughSmall } from "./TextToolbar"; 7 + import { isMac } from "src/utils/isDevice"; 8 + export const FootnoteTextToolbar = (props: { 9 + setToolbarState: (s: "default" | "link") => void; 10 + }) => { 11 + return ( 12 + <> 13 + <TextDecorationButton 14 + tooltipContent={ 15 + <div className="flex flex-col gap-1 justify-center"> 16 + <div className="text-center">Bold </div> 17 + <div className="flex gap-1"> 18 + <ShortcutKey>{metaKey()}</ShortcutKey> +{" "} 19 + <ShortcutKey> B </ShortcutKey> 20 + </div> 21 + </div> 22 + } 23 + mark={schema.marks.strong} 24 + icon={<BoldSmall />} 25 + /> 26 + 27 + <TextDecorationButton 28 + tooltipContent={ 29 + <div className="flex flex-col gap-1 justify-center"> 30 + <div className="italic font-normal text-center">Italic</div> 31 + <div className="flex gap-1"> 32 + <ShortcutKey>{metaKey()}</ShortcutKey> +{" "} 33 + <ShortcutKey> I </ShortcutKey> 34 + </div> 35 + </div> 36 + } 37 + mark={schema.marks.em} 38 + icon={<ItalicSmall />} 39 + /> 40 + <TextDecorationButton 41 + tooltipContent={ 42 + <div className="flex flex-col gap-1 justify-center"> 43 + <div className="text-center font-normal line-through"> 44 + Strikethrough 45 + </div> 46 + <div className="flex gap-1"> 47 + {isMac() ? ( 48 + <> 49 + <ShortcutKey>⌘</ShortcutKey> +{" "} 50 + <ShortcutKey> Ctrl </ShortcutKey> +{" "} 51 + <ShortcutKey> X </ShortcutKey> 52 + </> 53 + ) : ( 54 + <> 55 + <ShortcutKey> Ctrl </ShortcutKey> +{" "} 56 + <ShortcutKey> Meta </ShortcutKey> +{" "} 57 + <ShortcutKey> X </ShortcutKey> 58 + </> 59 + )} 60 + </div> 61 + </div> 62 + } 63 + mark={schema.marks.strikethrough} 64 + icon={<StrikethroughSmall />} 65 + /> 66 + <Separator classname="h-6!" /> 67 + <LinkButton setToolbarState={props.setToolbarState} /> 68 + </> 69 + ); 70 + };
+77
components/Toolbar/FootnoteToolbarWrapper.tsx
··· 1 + "use client"; 2 + 3 + import React, { useEffect, useState } from "react"; 4 + import { InlineLinkToolbar } from "./InlineLinkToolbar"; 5 + import { useEditorStates } from "src/state/useEditorState"; 6 + import { useUIState } from "src/useUIState"; 7 + import * as Tooltip from "@radix-ui/react-tooltip"; 8 + import { addShortcut } from "src/shortcuts"; 9 + import { FootnoteTextToolbar } from "./FootnoteTextToolbar"; 10 + import { useIsMobile } from "src/hooks/isMobile"; 11 + import { CloseTiny } from "components/Icons/CloseTiny"; 12 + 13 + type FootnoteToolbarState = "default" | "link"; 14 + 15 + export const FootnoteToolbar = (props: { pageID: string }) => { 16 + let [toolbarState, setToolbarState] = useState<FootnoteToolbarState>("default"); 17 + let focusedEntity = useUIState((s) => s.focusedEntity); 18 + let activeEditor = useEditorStates((s) => 19 + focusedEntity ? s.editorStates[focusedEntity.entityID] : null, 20 + ); 21 + 22 + useEffect(() => { 23 + if (toolbarState !== "default") return; 24 + let removeShortcut = addShortcut({ 25 + metaKey: true, 26 + key: "k", 27 + handler: () => { 28 + setToolbarState("link"); 29 + }, 30 + }); 31 + return () => { 32 + removeShortcut(); 33 + }; 34 + }, [toolbarState]); 35 + 36 + let isMobile = useIsMobile(); 37 + return ( 38 + <Tooltip.Provider> 39 + <div 40 + className={`toolbar flex gap-2 items-center justify-between w-full 41 + ${isMobile ? "h-[calc(15px+var(--safe-padding-bottom))]" : "h-[26px]"}`} 42 + > 43 + <div className="toolbarOptions flex gap-1 sm:gap-[6px] items-center grow"> 44 + {toolbarState === "default" ? ( 45 + <FootnoteTextToolbar setToolbarState={setToolbarState} /> 46 + ) : toolbarState === "link" ? ( 47 + <InlineLinkToolbar 48 + onClose={() => { 49 + activeEditor?.view?.focus(); 50 + setToolbarState("default"); 51 + }} 52 + /> 53 + ) : null} 54 + </div> 55 + <button 56 + className="toolbarBackToDefault hover:text-accent-contrast" 57 + onMouseDown={(e) => { 58 + e.preventDefault(); 59 + if (toolbarState === "default") { 60 + useUIState.setState(() => ({ 61 + focusedEntity: { 62 + entityType: "page", 63 + entityID: props.pageID, 64 + }, 65 + selectedBlocks: [], 66 + })); 67 + } else { 68 + setToolbarState("default"); 69 + } 70 + }} 71 + > 72 + <CloseTiny /> 73 + </button> 74 + </div> 75 + </Tooltip.Provider> 76 + ); 77 + };
+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 + 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 + <FootnoteButton /> 83 85 </> 84 86 ); 85 87 };
+21
lexicons/api/lexicons.ts
··· 2035 2035 'lex:pub.leaflet.richtext.facet#id', 2036 2036 'lex:pub.leaflet.richtext.facet#bold', 2037 2037 'lex:pub.leaflet.richtext.facet#italic', 2038 + 'lex:pub.leaflet.richtext.facet#footnote', 2038 2039 ], 2039 2040 }, 2040 2041 }, ··· 2135 2136 description: 'Facet feature for italic text', 2136 2137 required: [], 2137 2138 properties: {}, 2139 + }, 2140 + footnote: { 2141 + type: 'object', 2142 + description: 'Facet feature for a footnote reference', 2143 + required: ['footnoteId', 'contentPlaintext'], 2144 + properties: { 2145 + footnoteId: { 2146 + type: 'string', 2147 + }, 2148 + contentPlaintext: { 2149 + type: 'string', 2150 + }, 2151 + contentFacets: { 2152 + type: 'array', 2153 + items: { 2154 + type: 'ref', 2155 + ref: 'lex:pub.leaflet.richtext.facet', 2156 + }, 2157 + }, 2158 + }, 2138 2159 }, 2139 2160 }, 2140 2161 },
+19
lexicons/api/types/pub/leaflet/richtext/facet.ts
··· 29 29 | $Typed<Id> 30 30 | $Typed<Bold> 31 31 | $Typed<Italic> 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 + 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 28 "#strikethrough", 29 29 "#id", 30 30 "#bold", 31 - "#italic" 31 + "#italic", 32 + "#footnote" 32 33 ] 33 34 } 34 35 } ··· 135 136 "description": "Facet feature for italic text", 136 137 "required": [], 137 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 + } 138 159 } 139 160 } 140 161 }
+4
src/replicache/attributes.ts
··· 107 107 type: "number", 108 108 cardinality: "one", 109 109 }, 110 + "block/footnote": { 111 + type: "ordered-reference", 112 + cardinality: "many", 113 + }, 110 114 } as const; 111 115 112 116 const MailboxAttributes = {
+33
src/replicache/mutations.ts
··· 720 720 }); 721 721 }; 722 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 + 723 754 export const mutations = { 724 755 retractAttribute, 725 756 addBlock, ··· 743 774 addPollOption, 744 775 removePollOption, 745 776 updatePublicationDraft, 777 + createFootnote, 778 + deleteFootnote, 746 779 };
+2
src/useUIState.ts
··· 10 10 focusedEntity: null as 11 11 | { entityType: "page"; entityID: string } 12 12 | { entityType: "block"; entityID: string; parent: string } 13 + | { entityType: "footnote"; entityID: string; parent: string } 13 14 | null, 14 15 foldedBlocks: [] as string[], 15 16 openPages: [] as string[], ··· 47 48 b: 48 49 | { entityType: "page"; entityID: string } 49 50 | { entityType: "block"; entityID: string; parent: string } 51 + | { entityType: "footnote"; entityID: string; parent: string } 50 52 | null, 51 53 ) => set(() => ({ focusedEntity: b })), 52 54 setSelectedBlock: (block: SelectedBlock) =>
+13
src/utils/deleteBlock.ts
··· 113 113 // close the pages 114 114 pagesToClose.forEach((page) => page && useUIState.getState().closePage(page)); 115 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 + 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 + // Handle footnote nodes - emit placeholder 29 + if (node.nodeName === "footnote") { 30 + return "*"; 31 + } 28 32 return node 29 33 .toArray() 30 34 .map((f) => YJSFragmentToString(f))