a tool for shared writing and social publishing

add footnotes

+1362 -32
+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 }
+62
app/globals.css
··· 489 489 animation-iteration-count: 1; 490 490 animation-timing-function: ease-in; 491 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 20 renderers={{ 21 21 DidMention: DidMentionWithPopover, 22 22 }} 23 + footnoteIndexMap={props.footnoteIndexMap} 23 24 /> 24 25 ); 25 26 }
+2 -1
app/lish/[did]/[publication]/[rkey]/Blocks/TextBlock.tsx
··· 12 12 index: number[]; 13 13 preview?: boolean; 14 14 pageId?: string; 15 + 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 + }
+116
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 + 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 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"; 25 30 26 31 export function LinearDocumentPage({ 27 32 blocks, ··· 47 52 } = props; 48 53 let drawer = useDrawerOpen(document_uri); 49 54 const { pages } = useLeafletContent(); 55 + const footnotes = collectFootnotesFromBlocks(blocks); 56 + const footnoteIndexMap = buildFootnoteIndexMap(footnotes); 50 57 51 58 if (!document) return null; 52 59 ··· 78 85 blocks={blocks} 79 86 did={did} 80 87 prerenderedCodeBlocks={prerenderedCodeBlocks} 88 + footnoteIndexMap={footnoteIndexMap} 81 89 /> 90 + <PublishedFootnoteSection footnotes={footnotes} /> 82 91 <PostSubscribe /> 83 92 <PostPrevNextButtons 84 93 showPrevNext={preferences.showPrevNext !== false && !isSubpage}
+11
app/lish/[did]/[publication]/[rkey]/PostContent.tsx
··· 45 45 pageId, 46 46 pages, 47 47 pollData, 48 + footnoteIndexMap, 48 49 }: { 49 50 blocks: PubLeafletPagesLinearDocument.Block[]; 50 51 pageId?: string; ··· 55 56 bskyPostData: AppBskyFeedDefs.PostView[]; 56 57 pollData: PollData[]; 57 58 pages: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[]; 59 + footnoteIndexMap?: Map<string, number>; 58 60 }) { 59 61 return ( 60 62 <div ··· 75 77 preview={preview} 76 78 prerenderedCodeBlocks={prerenderedCodeBlocks} 77 79 pollData={pollData} 80 + footnoteIndexMap={footnoteIndexMap} 78 81 /> 79 82 ); 80 83 })} ··· 94 97 pageId, 95 98 pages, 96 99 pollData, 100 + footnoteIndexMap, 97 101 }: { 98 102 pageId?: string; 99 103 preview?: boolean; ··· 106 110 prerenderedCodeBlocks?: Map<string, string>; 107 111 bskyPostData: AppBskyFeedDefs.PostView[]; 108 112 pollData: PollData[]; 113 + footnoteIndexMap?: Map<string, number>; 109 114 }) => { 110 115 let b = block; 111 116 let blockProps = { ··· 361 366 index={index} 362 367 preview={preview} 363 368 pageId={pageId} 369 + footnoteIndexMap={footnoteIndexMap} 364 370 /> 365 371 </blockquote> 366 372 ); ··· 377 383 index={index} 378 384 preview={preview} 379 385 pageId={pageId} 386 + footnoteIndexMap={footnoteIndexMap} 380 387 /> 381 388 </p> 382 389 ); ··· 390 397 index={index} 391 398 preview={preview} 392 399 pageId={pageId} 400 + footnoteIndexMap={footnoteIndexMap} 393 401 /> 394 402 </h2> 395 403 ); ··· 401 409 index={index} 402 410 preview={preview} 403 411 pageId={pageId} 412 + footnoteIndexMap={footnoteIndexMap} 404 413 /> 405 414 </h3> 406 415 ); ··· 412 421 index={index} 413 422 preview={preview} 414 423 pageId={pageId} 424 + footnoteIndexMap={footnoteIndexMap} 415 425 /> 416 426 </h4> 417 427 ); ··· 424 434 index={index} 425 435 preview={preview} 426 436 pageId={pageId} 437 + footnoteIndexMap={footnoteIndexMap} 427 438 /> 428 439 </h6> 429 440 );
+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 &&
+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 + }
+64
components/Blocks/TextBlock/mountProsemirror.ts
··· 24 24 import { BlockProps } from "../Block"; 25 25 import { useEntitySetContext } from "components/EntitySetProvider"; 26 26 import { didToBlueskyUrl, atUriToUrl } from "src/utils/mentionUtils"; 27 + import { useFootnotePopoverStore } from "components/Footnotes/FootnotePopover"; 27 28 28 29 export function useMountProsemirror({ 29 30 props, ··· 81 82 handleClickOn: (_view, _pos, node, _nodePos, _event, direct) => { 82 83 if (!direct) return; 83 84 85 + // 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 + 84 125 // Check for didMention inline nodes 85 126 if (node?.type === schema.nodes.didMention) { 86 127 window.open( ··· 146 187 let addToHistory = tr.getMeta("addToHistory"); 147 188 let isBulkOp = tr.getMeta("bulkOp"); 148 189 let docHasChanges = tr.steps.length !== 0 || tr.docChanged; 190 + 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 + } 149 213 150 214 // Handle undo/redo history with timeout-based grouping 151 215 if (addToHistory !== false && docHasChanges) {
+35
components/Blocks/TextBlock/schema.ts
··· 194 194 ]; 195 195 }, 196 196 } as NodeSpec, 197 + 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
components/Footnotes/FootnoteContext.tsx
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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 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 + /> 74 + } 75 + footnoteSideColumn={ 76 + <FootnoteSideColumn 77 + pageEntityID={props.entityID} 78 + visible={sideColumnVisible} 79 + /> 47 80 } 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} /> 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> 70 92 </CardThemeProvider> 71 93 ); 72 94 } ··· 75 97 id: string; 76 98 children: React.ReactNode; 77 99 pageOptions?: React.ReactNode; 100 + footnoteSideColumn?: React.ReactNode; 78 101 fullPageScroll: boolean; 79 102 isFocused?: boolean; 80 103 onClickAction?: (e: React.MouseEvent) => void; ··· 132 155 </div> 133 156 </div> 134 157 {props.pageOptions} 158 + {props.footnoteSideColumn} 135 159 </div> 136 160 ); 137 161 }; ··· 205 229 /> 206 230 ) : null} 207 231 <Blocks entityID={props.entityID} /> 232 + <FootnoteSection /> 208 233 <div className="h-4 sm:h-6 w-full" /> 209 234 {/* we handle page bg in this sepate div so that 210 235 we can apply an opacity the background image
+67
components/Toolbar/FootnoteButton.tsx
··· 1 + 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 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
··· 2027 2027 'lex:pub.leaflet.richtext.facet#id', 2028 2028 'lex:pub.leaflet.richtext.facet#bold', 2029 2029 'lex:pub.leaflet.richtext.facet#italic', 2030 + 'lex:pub.leaflet.richtext.facet#footnote', 2030 2031 ], 2031 2032 }, 2032 2033 }, ··· 2127 2128 description: 'Facet feature for italic text', 2128 2129 required: [], 2129 2130 properties: {}, 2131 + }, 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 + }, 2130 2151 }, 2131 2152 }, 2132 2153 },
+19
lexicons/api/types/pub/leaflet/richtext/facet.ts
··· 29 29 | $Typed<Id> 30 30 | $Typed<Bold> 31 31 | $Typed<Italic> 32 + | $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 };
+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))