diff --git a/actions/publishToPublication.ts b/actions/publishToPublication.ts index 2dd62d89..fe2988dd 100644 --- a/actions/publishToPublication.ts +++ b/actions/publishToPublication.ts @@ -485,6 +485,17 @@ async function processBlocksToPages( ).flat(); } async function blockToRecord(b: Block, did: string) { + const footnoteContentResolver = (footnoteEntityID: string) => { + let [content] = scan.eav(footnoteEntityID, "block/text"); + if (!content) return { plaintext: "", facets: [] as PubLeafletRichtextFacet.Main[] }; + let doc = new Y.Doc(); + const update = base64.toByteArray(content.data.value); + Y.applyUpdate(doc, update); + let nodes = doc.getXmlElement("prosemirror").toArray(); + let plaintext = YJSFragmentToString(nodes[0]); + let { facets } = YJSFragmentToFacets(nodes[0]); + return { plaintext, facets }; + }; const getBlockContent = (b: string) => { let [content] = scan.eav(b, "block/text"); if (!content) return ["", [] as PubLeafletRichtextFacet.Main[]] as const; @@ -493,7 +504,7 @@ async function processBlocksToPages( Y.applyUpdate(doc, update); let nodes = doc.getXmlElement("prosemirror").toArray(); let stringValue = YJSFragmentToString(nodes[0]); - let { facets } = YJSFragmentToFacets(nodes[0]); + let { facets } = YJSFragmentToFacets(nodes[0], 0, footnoteContentResolver); return [stringValue, facets] as const; }; if (b.type === "card") { @@ -759,8 +770,34 @@ async function processBlocksToPages( function YJSFragmentToFacets( node: Y.XmlElement | Y.XmlText | Y.XmlHook, byteOffset: number = 0, + footnoteContentResolver?: (footnoteEntityID: string) => { plaintext: string; facets: PubLeafletRichtextFacet.Main[] }, ): { facets: PubLeafletRichtextFacet.Main[]; byteLength: number } { if (node.constructor === Y.XmlElement) { + // Handle footnote inline nodes + if (node.nodeName === "footnote") { + const footnoteEntityID = node.getAttribute("footnoteEntityID") || ""; + const placeholder = "*"; + const unicodestring = new UnicodeString(placeholder); + let footnoteContent = footnoteContentResolver?.(footnoteEntityID); + const facet: PubLeafletRichtextFacet.Main = { + index: { + byteStart: byteOffset, + byteEnd: byteOffset + unicodestring.length, + }, + features: [ + { + $type: "pub.leaflet.richtext.facet#footnote", + footnoteId: footnoteEntityID, + contentPlaintext: footnoteContent?.plaintext || "", + ...(footnoteContent?.facets?.length + ? { contentFacets: footnoteContent.facets } + : {}), + }, + ], + }; + return { facets: [facet], byteLength: unicodestring.length }; + } + // Handle inline mention nodes if (node.nodeName === "didMention") { const text = node.getAttribute("text") || ""; @@ -807,7 +844,7 @@ function YJSFragmentToFacets( let allFacets: PubLeafletRichtextFacet.Main[] = []; let currentOffset = byteOffset; for (const child of node.toArray()) { - const result = YJSFragmentToFacets(child, currentOffset); + const result = YJSFragmentToFacets(child, currentOffset, footnoteContentResolver); allFacets.push(...result.facets); currentOffset += result.byteLength; } diff --git a/app/globals.css b/app/globals.css index 73d26f5b..8a475218 100644 --- a/app/globals.css +++ b/app/globals.css @@ -489,3 +489,65 @@ pre.shiki { animation-iteration-count: 1; animation-timing-function: ease-in; } + +.postPageContent { + counter-reset: footnote; +} +.footnote-ref { + counter-increment: footnote; + cursor: pointer; + color: rgb(var(--accent-contrast)); + opacity: 0.7; +} +.footnote-ref::after { + content: counter(footnote); + vertical-align: super; + font-size: 75%; +} +.footnote-ref ~ br.ProseMirror-trailingBreak { + display: inline; + width: 4px; +} +.footnote-ref ~ img.ProseMirror-separator { + display: none; +} + +.footnote-side-enter { + animation: footnote-fade-in 200ms ease-out; +} +@keyframes footnote-fade-in { + from { + opacity: 0; + transform: translateX(-8px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +.footnote-side-item { + max-height: 4.5em; + overflow: hidden; + transition: max-height 200ms ease; +} +.footnote-side-item.has-overflow::after { + content: ""; + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 1.5em; + background: linear-gradient(to bottom, transparent, rgb(var(--bg-page))); + pointer-events: none; + opacity: 1; + transition: opacity 200ms ease; +} +.footnote-side-item:hover, +.footnote-side-item:focus-within { + max-height: 40em; +} +.footnote-side-item:hover::after, +.footnote-side-item:focus-within::after { + opacity: 0; +} diff --git a/app/lish/[did]/[publication]/[rkey]/Blocks/BaseTextBlock.tsx b/app/lish/[did]/[publication]/[rkey]/Blocks/BaseTextBlock.tsx index 50feeb13..65a58881 100644 --- a/app/lish/[did]/[publication]/[rkey]/Blocks/BaseTextBlock.tsx +++ b/app/lish/[did]/[publication]/[rkey]/Blocks/BaseTextBlock.tsx @@ -20,6 +20,7 @@ export function BaseTextBlock(props: Omit) { renderers={{ DidMention: DidMentionWithPopover, }} + footnoteIndexMap={props.footnoteIndexMap} /> ); } diff --git a/app/lish/[did]/[publication]/[rkey]/Blocks/TextBlock.tsx b/app/lish/[did]/[publication]/[rkey]/Blocks/TextBlock.tsx index b102b94e..3f3196b5 100644 --- a/app/lish/[did]/[publication]/[rkey]/Blocks/TextBlock.tsx +++ b/app/lish/[did]/[publication]/[rkey]/Blocks/TextBlock.tsx @@ -12,6 +12,7 @@ export function TextBlock(props: { index: number[]; preview?: boolean; pageId?: string; + footnoteIndexMap?: Map; }) { let children = []; let highlights = useHighlight(props.index, props.pageId); @@ -48,7 +49,7 @@ export function TextBlock(props: { } return facets; }, [props.plaintext, props.facets, highlights, props.preview, props.pageId]); - return ; + return ; } function addFacet(facets: Facet[], newFacet: Facet, length: number) { diff --git a/app/lish/[did]/[publication]/[rkey]/Blocks/TextBlockCore.tsx b/app/lish/[did]/[publication]/[rkey]/Blocks/TextBlockCore.tsx index 5ef5bdf0..0fb82af3 100644 --- a/app/lish/[did]/[publication]/[rkey]/Blocks/TextBlockCore.tsx +++ b/app/lish/[did]/[publication]/[rkey]/Blocks/TextBlockCore.tsx @@ -7,6 +7,7 @@ type Facet = PubLeafletRichtextFacet.Main; export type FacetRenderers = { DidMention?: (props: { did: string; children: ReactNode }) => ReactNode; + FootnoteRef?: (props: { footnoteId: string; index: number; children: ReactNode }) => ReactNode; }; export type TextBlockCoreProps = { @@ -15,6 +16,7 @@ export type TextBlockCoreProps = { index: number[]; preview?: boolean; renderers?: FacetRenderers; + footnoteIndexMap?: Map; }; export function TextBlockCore(props: TextBlockCoreProps) { @@ -38,9 +40,31 @@ export function TextBlockCore(props: TextBlockCoreProps) { let isAtMention = segment.facet?.find(PubLeafletRichtextFacet.isAtMention); let isUnderline = segment.facet?.find(PubLeafletRichtextFacet.isUnderline); let isItalic = segment.facet?.find(PubLeafletRichtextFacet.isItalic); + let isFootnote = segment.facet?.find(PubLeafletRichtextFacet.isFootnote); let isHighlighted = segment.facet?.find( PubLeafletRichtextFacet.isHighlight, ); + + if (isFootnote) { + let fnIndex = props.footnoteIndexMap?.get(isFootnote.footnoteId) ?? 0; + const FootnoteRenderer = props.renderers?.FootnoteRef; + if (FootnoteRenderer) { + children.push( + + {fnIndex} + , + ); + } else { + children.push( + + {fnIndex} + , + ); + } + counter++; + continue; + } + let className = ` ${isCode ? "inline-code" : ""} ${id ? "scroll-mt-12 scroll-mb-10" : ""} diff --git a/app/lish/[did]/[publication]/[rkey]/Footnotes/FootnoteCollectorContext.tsx b/app/lish/[did]/[publication]/[rkey]/Footnotes/FootnoteCollectorContext.tsx new file mode 100644 index 00000000..33e57e3d --- /dev/null +++ b/app/lish/[did]/[publication]/[rkey]/Footnotes/FootnoteCollectorContext.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { createContext, useContext, useRef, useCallback, useMemo } from "react"; +import { PubLeafletRichtextFacet } from "lexicons/api"; + +export type CollectedFootnote = { + footnoteId: string; + index: number; + contentPlaintext: string; + contentFacets?: PubLeafletRichtextFacet.Main[]; +}; + +type FootnoteCollectorContextValue = { + registerFootnote: (footnote: Omit) => number; + getFootnotes: () => CollectedFootnote[]; +}; + +const FootnoteCollectorContext = createContext({ + registerFootnote: () => 0, + getFootnotes: () => [], +}); + +export function useFootnoteCollector() { + return useContext(FootnoteCollectorContext); +} + +export function FootnoteCollectorProvider(props: { children: React.ReactNode }) { + let footnotesRef = useRef([]); + let counterRef = useRef(1); + + let registerFootnote = useCallback( + (footnote: Omit) => { + let existing = footnotesRef.current.find( + (f) => f.footnoteId === footnote.footnoteId, + ); + if (existing) return existing.index; + let index = counterRef.current++; + footnotesRef.current.push({ ...footnote, index }); + return index; + }, + [], + ); + + let getFootnotes = useCallback(() => footnotesRef.current, []); + + let value = useMemo( + () => ({ registerFootnote, getFootnotes }), + [registerFootnote, getFootnotes], + ); + + return ( + + {props.children} + + ); +} diff --git a/app/lish/[did]/[publication]/[rkey]/Footnotes/PublishedFootnotes.tsx b/app/lish/[did]/[publication]/[rkey]/Footnotes/PublishedFootnotes.tsx new file mode 100644 index 00000000..f7feca84 --- /dev/null +++ b/app/lish/[did]/[publication]/[rkey]/Footnotes/PublishedFootnotes.tsx @@ -0,0 +1,116 @@ +"use client"; + +import { + PubLeafletRichtextFacet, + PubLeafletBlocksText, + PubLeafletBlocksHeader, + PubLeafletBlocksBlockquote, + PubLeafletPagesLinearDocument, +} from "lexicons/api"; +import { TextBlockCore } from "../Blocks/TextBlockCore"; + +export type PublishedFootnote = { + footnoteId: string; + index: number; + contentPlaintext: string; + contentFacets?: PubLeafletRichtextFacet.Main[]; +}; + +export function collectFootnotesFromBlocks( + blocks: PubLeafletPagesLinearDocument.Block[], +): PublishedFootnote[] { + let footnotes: PublishedFootnote[] = []; + let seen = new Set(); + let idx = 1; + + function scanFacets(facets?: PubLeafletRichtextFacet.Main[]) { + if (!facets) return; + for (let facet of facets) { + for (let feature of facet.features) { + if (PubLeafletRichtextFacet.isFootnote(feature)) { + if (!seen.has(feature.footnoteId)) { + seen.add(feature.footnoteId); + footnotes.push({ + footnoteId: feature.footnoteId, + index: idx++, + contentPlaintext: feature.contentPlaintext, + contentFacets: feature.contentFacets, + }); + } + } + } + } + } + + for (let b of blocks) { + let block = b.block; + let facets: PubLeafletRichtextFacet.Main[] | undefined; + if (PubLeafletBlocksText.isMain(block)) { + facets = block.facets; + } else if (PubLeafletBlocksHeader.isMain(block)) { + facets = block.facets; + } else if (PubLeafletBlocksBlockquote.isMain(block)) { + facets = block.facets; + } + if (facets) scanFacets(facets); + } + + return footnotes; +} + +export function buildFootnoteIndexMap( + footnotes: PublishedFootnote[], +): Map { + let map = new Map(); + for (let fn of footnotes) { + map.set(fn.footnoteId, fn.index); + } + return map; +} + +export function PublishedFootnoteSection(props: { + footnotes: PublishedFootnote[]; +}) { + if (props.footnotes.length === 0) return null; + + return ( +
+
+
+ {props.footnotes.map((fn) => ( +
+ + {fn.index}. + +
+ {fn.contentPlaintext ? ( + + ) : ( + Empty footnote + )} +
+ + ↩ + +
+ ))} +
+
+ ); +} diff --git a/app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx b/app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx index 04b9d7d9..07d0cd5c 100644 --- a/app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx +++ b/app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx @@ -22,6 +22,11 @@ import { PollData } from "./fetchPollData"; import { SharedPageProps } from "./PostPages"; import { PostPrevNextButtons } from "./PostPrevNextButtons"; import { PostSubscribe } from "./PostSubscribe"; +import { + collectFootnotesFromBlocks, + buildFootnoteIndexMap, + PublishedFootnoteSection, +} from "./Footnotes/PublishedFootnotes"; export function LinearDocumentPage({ blocks, @@ -47,6 +52,8 @@ export function LinearDocumentPage({ } = props; let drawer = useDrawerOpen(document_uri); const { pages } = useLeafletContent(); + const footnotes = collectFootnotesFromBlocks(blocks); + const footnoteIndexMap = buildFootnoteIndexMap(footnotes); if (!document) return null; @@ -78,7 +85,9 @@ export function LinearDocumentPage({ blocks={blocks} did={did} prerenderedCodeBlocks={prerenderedCodeBlocks} + footnoteIndexMap={footnoteIndexMap} /> + ; }) { return (
); })} @@ -94,6 +97,7 @@ export let Block = ({ pageId, pages, pollData, + footnoteIndexMap, }: { pageId?: string; preview?: boolean; @@ -106,6 +110,7 @@ export let Block = ({ prerenderedCodeBlocks?: Map; bskyPostData: AppBskyFeedDefs.PostView[]; pollData: PollData[]; + footnoteIndexMap?: Map; }) => { let b = block; let blockProps = { @@ -361,6 +366,7 @@ export let Block = ({ index={index} preview={preview} pageId={pageId} + footnoteIndexMap={footnoteIndexMap} /> ); @@ -377,6 +383,7 @@ export let Block = ({ index={index} preview={preview} pageId={pageId} + footnoteIndexMap={footnoteIndexMap} />

); @@ -390,6 +397,7 @@ export let Block = ({ index={index} preview={preview} pageId={pageId} + footnoteIndexMap={footnoteIndexMap} /> ); @@ -401,6 +409,7 @@ export let Block = ({ index={index} preview={preview} pageId={pageId} + footnoteIndexMap={footnoteIndexMap} /> ); @@ -412,6 +421,7 @@ export let Block = ({ index={index} preview={preview} pageId={pageId} + footnoteIndexMap={footnoteIndexMap} /> ); @@ -424,6 +434,7 @@ export let Block = ({ index={index} preview={preview} pageId={pageId} + footnoteIndexMap={footnoteIndexMap} /> ); diff --git a/components/Blocks/TextBlock/RenderYJSFragment.tsx b/components/Blocks/TextBlock/RenderYJSFragment.tsx index 35c97cad..fb12897f 100644 --- a/components/Blocks/TextBlock/RenderYJSFragment.tsx +++ b/components/Blocks/TextBlock/RenderYJSFragment.tsx @@ -91,6 +91,21 @@ export function RenderYJSFragment({ ); } + // Handle footnote inline nodes + if ( + node.constructor === XmlElement && + node.nodeName === "footnote" + ) { + const id = node.getAttribute("footnoteEntityID") || ""; + return ( + + ); + } + // Handle atMention inline nodes if ( node.constructor === XmlElement && diff --git a/components/Blocks/TextBlock/inputRules.ts b/components/Blocks/TextBlock/inputRules.ts index 9dbbd8c6..03494520 100644 --- a/components/Blocks/TextBlock/inputRules.ts +++ b/components/Blocks/TextBlock/inputRules.ts @@ -12,6 +12,8 @@ import { schema } from "./schema"; import { useUIState } from "src/useUIState"; import { flushSync } from "react-dom"; import { LAST_USED_CODE_LANGUAGE_KEY } from "src/utils/codeLanguageStorage"; +import { insertFootnote } from "./insertFootnote"; +import { useEditorStates } from "src/state/useEditorState"; export const inputrules = ( propsRef: MutableRefObject, repRef: MutableRefObject | null>, @@ -227,6 +229,22 @@ export const inputrules = ( return tr; }), + // Footnote - [^ triggers footnote insertion + new InputRule(/\[\^$/, (state, match, start, end) => { + let tr = state.tr.delete(start, end); + setTimeout(() => { + let view = useEditorStates.getState().editorStates[propsRef.current.entityID]?.view; + if (!view || !repRef.current) return; + insertFootnote( + view, + propsRef.current.entityID, + repRef.current, + propsRef.current.entity_set.set, + ); + }, 0); + return tr; + }), + // Mention - @ at start of line, after space, or after hard break new InputRule(/(?:^|\s)@$/, (state, match, start, end) => { if (!openMentionAutocomplete) return null; diff --git a/components/Blocks/TextBlock/insertFootnote.ts b/components/Blocks/TextBlock/insertFootnote.ts new file mode 100644 index 00000000..7f83658a --- /dev/null +++ b/components/Blocks/TextBlock/insertFootnote.ts @@ -0,0 +1,43 @@ +import { EditorView } from "prosemirror-view"; +import { v7 } from "uuid"; +import { Replicache } from "replicache"; +import type { ReplicacheMutators } from "src/replicache"; +import { schema } from "./schema"; +import { generateKeyBetween } from "fractional-indexing"; +import { scanIndex } from "src/replicache/utils"; + +export async function insertFootnote( + view: EditorView, + blockID: string, + rep: Replicache, + permissionSet: string, +) { + let footnoteEntityID = v7(); + + let existingFootnotes = await rep.query(async (tx) => { + let scan = scanIndex(tx); + return scan.eav(blockID, "block/footnote"); + }); + let lastPosition = + existingFootnotes.length > 0 + ? existingFootnotes + .map((f) => f.data.position) + .sort() + .at(-1)! + : null; + let position = generateKeyBetween(lastPosition, null); + + await rep.mutate.createFootnote({ + footnoteEntityID, + blockID, + permission_set: permissionSet, + position, + }); + + let node = schema.nodes.footnote.create({ footnoteEntityID }); + let { from } = view.state.selection; + let tr = view.state.tr.insert(from, node); + view.dispatch(tr); + + return footnoteEntityID; +} diff --git a/components/Blocks/TextBlock/mountProsemirror.ts b/components/Blocks/TextBlock/mountProsemirror.ts index ff646a80..e9491217 100644 --- a/components/Blocks/TextBlock/mountProsemirror.ts +++ b/components/Blocks/TextBlock/mountProsemirror.ts @@ -24,6 +24,7 @@ import { useHandlePaste } from "./useHandlePaste"; import { BlockProps } from "../Block"; import { useEntitySetContext } from "components/EntitySetProvider"; import { didToBlueskyUrl, atUriToUrl } from "src/utils/mentionUtils"; +import { useFootnotePopoverStore } from "components/Footnotes/FootnotePopover"; export function useMountProsemirror({ props, @@ -81,6 +82,46 @@ export function useMountProsemirror({ handleClickOn: (_view, _pos, node, _nodePos, _event, direct) => { if (!direct) return; + // Check for footnote inline nodes + if (node?.type === schema.nodes.footnote) { + let footnoteID = node.attrs.footnoteEntityID; + let supEl = _event.target as HTMLElement; + let sup = supEl.closest(".footnote-ref") as HTMLElement | null; + if (!sup) return; + + // On mobile/tablet, show popover + let isDesktop = window.matchMedia("(min-width: 1024px)").matches; + if (!isDesktop) { + let store = useFootnotePopoverStore.getState(); + if (store.activeFootnoteID === footnoteID) { + store.close(); + } else { + store.open(footnoteID, sup); + } + return; + } + + // On desktop, prefer the side column editor if visible + let sideColumn = document.querySelector(".footnote-side-column"); + let editor = sideColumn?.querySelector( + `[data-footnote-editor="${footnoteID}"]`, + ) as HTMLElement | null; + // Fall back to the bottom section + if (!editor) { + editor = document.querySelector( + `[data-footnote-editor="${footnoteID}"]`, + ) as HTMLElement | null; + } + if (editor) { + editor.scrollIntoView({ behavior: "smooth", block: "nearest" }); + let pm = editor.querySelector(".ProseMirror") as HTMLElement | null; + if (pm) { + setTimeout(() => pm!.focus(), 100); + } + } + return; + } + // Check for didMention inline nodes if (node?.type === schema.nodes.didMention) { window.open( @@ -147,6 +188,29 @@ export function useMountProsemirror({ let isBulkOp = tr.getMeta("bulkOp"); let docHasChanges = tr.steps.length !== 0 || tr.docChanged; + // Diff for removed/added footnote nodes + if (docHasChanges) { + let oldFootnotes = new Set(); + let newFootnotes = new Set(); + oldEditorState.doc.descendants((n) => { + if (n.type.name === "footnote") + oldFootnotes.add(n.attrs.footnoteEntityID); + }); + newState.doc.descendants((n) => { + if (n.type.name === "footnote") + newFootnotes.add(n.attrs.footnoteEntityID); + }); + // Removed footnotes + for (let id of oldFootnotes) { + if (!newFootnotes.has(id)) { + repRef.current?.mutate.deleteFootnote({ + footnoteEntityID: id, + blockID: entityID, + }); + } + } + } + // Handle undo/redo history with timeout-based grouping if (addToHistory !== false && docHasChanges) { if (actionTimeout.current) window.clearTimeout(actionTimeout.current); diff --git a/components/Blocks/TextBlock/schema.ts b/components/Blocks/TextBlock/schema.ts index ab9ff2a6..88b2ed91 100644 --- a/components/Blocks/TextBlock/schema.ts +++ b/components/Blocks/TextBlock/schema.ts @@ -194,6 +194,41 @@ let baseSchema = { ]; }, } as NodeSpec, + footnote: { + attrs: { footnoteEntityID: {} }, + group: "inline", + inline: true, + atom: true, + selectable: false, + draggable: false, + parseDOM: [ + { + tag: "span.footnote-ref", + getAttrs(dom: HTMLElement) { + return { + footnoteEntityID: dom.getAttribute("data-footnote-id"), + }; + }, + }, + { + tag: "sup.footnote-ref", + getAttrs(dom: HTMLElement) { + return { + footnoteEntityID: dom.getAttribute("data-footnote-id"), + }; + }, + }, + ], + toDOM(node) { + return [ + "span", + { + class: "footnote-ref", + "data-footnote-id": node.attrs.footnoteEntityID, + }, + ]; + }, + } as NodeSpec, didMention: { attrs: { did: {}, diff --git a/components/Footnotes/FootnoteContext.tsx b/components/Footnotes/FootnoteContext.tsx new file mode 100644 index 00000000..c656d7e2 --- /dev/null +++ b/components/Footnotes/FootnoteContext.tsx @@ -0,0 +1,16 @@ +import { createContext, useContext } from "react"; +import type { FootnoteInfo } from "./usePageFootnotes"; + +type FootnoteContextValue = { + footnotes: FootnoteInfo[]; + indexMap: Record; +}; + +export const FootnoteContext = createContext({ + footnotes: [], + indexMap: {}, +}); + +export function useFootnoteContext() { + return useContext(FootnoteContext); +} diff --git a/components/Footnotes/FootnoteEditor.tsx b/components/Footnotes/FootnoteEditor.tsx new file mode 100644 index 00000000..be04bb8c --- /dev/null +++ b/components/Footnotes/FootnoteEditor.tsx @@ -0,0 +1,156 @@ +import { useLayoutEffect, useRef, useState, useEffect } from "react"; +import { EditorState } from "prosemirror-state"; +import { EditorView } from "prosemirror-view"; +import { baseKeymap, toggleMark } from "prosemirror-commands"; +import { keymap } from "prosemirror-keymap"; +import { ySyncPlugin } from "y-prosemirror"; +import * as Y from "yjs"; +import * as base64 from "base64-js"; +import { schema } from "components/Blocks/TextBlock/schema"; +import { useEntity, useReplicache } from "src/replicache"; +import { autolink } from "components/Blocks/TextBlock/autolink-plugin"; +import { CloseTiny } from "components/Icons/CloseTiny"; + +export function FootnoteEditor(props: { + footnoteEntityID: string; + index: number; + editable: boolean; + onDelete?: () => void; + autoFocus?: boolean; +}) { + let mountRef = useRef(null); + let rep = useReplicache(); + let value = useFootnoteYJS(props.footnoteEntityID); + + useLayoutEffect(() => { + if (!mountRef.current || !value) return; + + let plugins = [ + ySyncPlugin(value), + keymap({ + "Meta-b": toggleMark(schema.marks.strong), + "Ctrl-b": toggleMark(schema.marks.strong), + "Meta-u": toggleMark(schema.marks.underline), + "Ctrl-u": toggleMark(schema.marks.underline), + "Meta-i": toggleMark(schema.marks.em), + "Ctrl-i": toggleMark(schema.marks.em), + "Shift-Enter": (state, dispatch) => { + let hardBreak = schema.nodes.hard_break.create(); + if (dispatch) { + dispatch( + state.tr.replaceSelectionWith(hardBreak).scrollIntoView(), + ); + } + return true; + }, + Enter: (_state, _dispatch, view) => { + view?.dom.blur(); + return true; + }, + }), + keymap(baseKeymap), + autolink({ + type: schema.marks.link, + shouldAutoLink: () => true, + defaultProtocol: "https", + }), + ]; + + let state = EditorState.create({ schema, plugins }); + let view = new EditorView( + { mount: mountRef.current }, + { + state, + editable: () => props.editable, + dispatchTransaction(this: EditorView, tr) { + let newState = this.state.apply(tr); + this.updateState(newState); + }, + }, + ); + + if (props.autoFocus) { + setTimeout(() => view.focus(), 50); + } + + return () => { + view.destroy(); + }; + }, [props.footnoteEntityID, value, props.editable, props.autoFocus]); + + return ( +
+ +
+ {props.editable && props.onDelete && ( + + )} +
+ ); +} + +function useFootnoteYJS(footnoteEntityID: string) { + const [ydoc] = useState(new Y.Doc()); + const docState = useEntity(footnoteEntityID, "block/text"); + let rep = useReplicache(); + const [yText] = useState(ydoc.getXmlFragment("prosemirror")); + + if (docState) { + const update = base64.toByteArray(docState.data.value); + Y.applyUpdate(ydoc, update); + } + + useEffect(() => { + if (!rep.rep) return; + let timeout = null as null | number; + const updateReplicache = async () => { + const update = Y.encodeStateAsUpdate(ydoc); + await rep.rep?.mutate.assertFact({ + ignoreUndo: true, + entity: footnoteEntityID, + attribute: "block/text", + data: { + value: base64.fromByteArray(update), + type: "text", + }, + }); + }; + const f = async (_events: Y.YEvent[], transaction: Y.Transaction) => { + if (!transaction.origin) return; + if (timeout) clearTimeout(timeout); + timeout = window.setTimeout(async () => { + updateReplicache(); + }, 300); + }; + + yText.observeDeep(f); + return () => { + yText.unobserveDeep(f); + }; + }, [yText, footnoteEntityID, rep, ydoc]); + + return yText; +} diff --git a/components/Footnotes/FootnotePopover.tsx b/components/Footnotes/FootnotePopover.tsx new file mode 100644 index 00000000..33142130 --- /dev/null +++ b/components/Footnotes/FootnotePopover.tsx @@ -0,0 +1,130 @@ +"use client"; + +import { useEffect, useRef, useState, useCallback } from "react"; +import { create } from "zustand"; +import { useFootnoteContext } from "./FootnoteContext"; +import { FootnoteEditor } from "./FootnoteEditor"; +import { useReplicache } from "src/replicache"; +import { useEntitySetContext } from "components/EntitySetProvider"; +import { deleteFootnoteFromBlock } from "./deleteFootnoteFromBlock"; + +type FootnotePopoverState = { + activeFootnoteID: string | null; + anchorElement: HTMLElement | null; + open: (footnoteID: string, anchor: HTMLElement) => void; + close: () => void; +}; + +export const useFootnotePopoverStore = create((set) => ({ + activeFootnoteID: null, + anchorElement: null, + open: (footnoteID, anchor) => + set({ activeFootnoteID: footnoteID, anchorElement: anchor }), + close: () => set({ activeFootnoteID: null, anchorElement: null }), +})); + +export function FootnotePopover() { + let { activeFootnoteID, anchorElement, close } = useFootnotePopoverStore(); + let { footnotes } = useFootnoteContext(); + let { permissions } = useEntitySetContext(); + let rep = useReplicache(); + let popoverRef = useRef(null); + let [position, setPosition] = useState<{ top: number; left: number; arrowLeft: number } | null>(null); + + let footnote = footnotes.find((fn) => fn.footnoteEntityID === activeFootnoteID); + + let updatePosition = useCallback(() => { + if (!anchorElement || !popoverRef.current) return; + + let anchorRect = anchorElement.getBoundingClientRect(); + let popoverWidth = popoverRef.current.offsetWidth; + let popoverHeight = popoverRef.current.offsetHeight; + + // Position above the anchor by default, fall back to below + let top = anchorRect.top - popoverHeight - 8; + let left = anchorRect.left + anchorRect.width / 2 - popoverWidth / 2; + + // Clamp horizontal position + let padding = 12; + left = Math.max(padding, Math.min(left, window.innerWidth - popoverWidth - padding)); + + // Arrow position relative to popover + let arrowLeft = anchorRect.left + anchorRect.width / 2 - left; + + // If not enough room above, show below + if (top < padding) { + top = anchorRect.bottom + 8; + } + + setPosition({ top, left, arrowLeft }); + }, [anchorElement]); + + useEffect(() => { + if (!activeFootnoteID || !anchorElement) { + setPosition(null); + return; + } + + // Delay to let the popover render so we can measure it + requestAnimationFrame(updatePosition); + + let handleClickOutside = (e: Event) => { + let target = e.target as Node; + if ( + popoverRef.current && + !popoverRef.current.contains(target) && + !anchorElement.contains(target) + ) { + close(); + } + }; + + let handleScroll = () => close(); + + document.addEventListener("mousedown", handleClickOutside); + document.addEventListener("touchstart", handleClickOutside); + // Close on scroll of any scroll container + let scrollWrapper = anchorElement.closest(".pageScrollWrapper"); + scrollWrapper?.addEventListener("scroll", handleScroll); + window.addEventListener("resize", close); + + return () => { + document.removeEventListener("mousedown", handleClickOutside); + document.removeEventListener("touchstart", handleClickOutside); + scrollWrapper?.removeEventListener("scroll", handleScroll); + window.removeEventListener("resize", close); + }; + }, [activeFootnoteID, anchorElement, close, updatePosition]); + + if (!activeFootnoteID || !footnote) return null; + + return ( +
+ { + deleteFootnoteFromBlock( + footnote.footnoteEntityID, + footnote.blockID, + rep.rep, + ); + close(); + } + : undefined + } + /> +
+ ); +} diff --git a/components/Footnotes/FootnoteSection.tsx b/components/Footnotes/FootnoteSection.tsx new file mode 100644 index 00000000..460f2e05 --- /dev/null +++ b/components/Footnotes/FootnoteSection.tsx @@ -0,0 +1,40 @@ +import { useFootnoteContext } from "./FootnoteContext"; +import { FootnoteEditor } from "./FootnoteEditor"; +import { useReplicache } from "src/replicache"; +import { useEntitySetContext } from "components/EntitySetProvider"; +import { deleteFootnoteFromBlock } from "./deleteFootnoteFromBlock"; + +export function FootnoteSection(props: { hiddenOnDesktop?: boolean }) { + let { footnotes } = useFootnoteContext(); + let { permissions } = useEntitySetContext(); + let rep = useReplicache(); + + if (footnotes.length === 0) return null; + + return ( +
+
+
+ {footnotes.map((fn) => ( + { + deleteFootnoteFromBlock( + fn.footnoteEntityID, + fn.blockID, + rep.rep, + ); + } + : undefined + } + /> + ))} +
+
+ ); +} diff --git a/components/Footnotes/FootnoteSideColumn.tsx b/components/Footnotes/FootnoteSideColumn.tsx new file mode 100644 index 00000000..6e27ef81 --- /dev/null +++ b/components/Footnotes/FootnoteSideColumn.tsx @@ -0,0 +1,207 @@ +import { useEffect, useRef, useState, useCallback } from "react"; +import { useFootnoteContext } from "./FootnoteContext"; +import { FootnoteEditor } from "./FootnoteEditor"; +import { useReplicache } from "src/replicache"; +import { useEntitySetContext } from "components/EntitySetProvider"; +import { deleteFootnoteFromBlock } from "./deleteFootnoteFromBlock"; + +type PositionedFootnote = { + footnoteEntityID: string; + blockID: string; + index: number; + top: number; +}; + +const GAP = 4; + +export function FootnoteSideColumn(props: { + pageEntityID: string; + visible: boolean; +}) { + let { footnotes } = useFootnoteContext(); + let { permissions } = useEntitySetContext(); + let rep = useReplicache(); + let containerRef = useRef(null); + let innerRef = useRef(null); + let [positions, setPositions] = useState([]); + let [scrollOffset, setScrollOffset] = useState(0); + + let calculatePositions = useCallback(() => { + let container = containerRef.current; + let inner = innerRef.current; + if (!container || !inner || footnotes.length === 0) { + setPositions([]); + return; + } + + let scrollWrapper = container.closest(".pageWrapper") + ?.querySelector(".pageScrollWrapper") as HTMLElement | null; + if (!scrollWrapper) return; + + let scrollTop = scrollWrapper.scrollTop; + let scrollWrapperRect = scrollWrapper.getBoundingClientRect(); + setScrollOffset(scrollTop); + + // Phase 1: Batch read — measure all anchor positions and item heights + let measurements: { + footnoteEntityID: string; + blockID: string; + index: number; + anchorTop: number; + height: number; + }[] = []; + + for (let fn of footnotes) { + let supEl = scrollWrapper.querySelector( + `.footnote-ref[data-footnote-id="${fn.footnoteEntityID}"]`, + ) as HTMLElement | null; + if (!supEl) continue; + + let supRect = supEl.getBoundingClientRect(); + let anchorTop = supRect.top - scrollWrapperRect.top + scrollTop; + + // Measure actual rendered height of the side item element + let itemEl = inner.querySelector( + `[data-footnote-side-id="${fn.footnoteEntityID}"]`, + ) as HTMLElement | null; + let height = itemEl ? itemEl.offsetHeight : 54; // fallback for first render + + measurements.push({ + footnoteEntityID: fn.footnoteEntityID, + blockID: fn.blockID, + index: fn.index, + anchorTop, + height, + }); + } + + // Phase 2: Resolve collisions using measured heights + let resolved: PositionedFootnote[] = []; + let nextAvailableTop = 0; + for (let m of measurements) { + let top = Math.max(m.anchorTop, nextAvailableTop); + resolved.push({ + footnoteEntityID: m.footnoteEntityID, + blockID: m.blockID, + index: m.index, + top, + }); + nextAvailableTop = top + m.height + GAP; + } + + // Phase 3: Batch write — set positions via state update (React handles DOM writes) + setPositions(resolved); + }, [footnotes]); + + useEffect(() => { + if (!props.visible) return; + calculatePositions(); + + let scrollWrapper = containerRef.current?.closest(".pageWrapper") + ?.querySelector(".pageScrollWrapper") as HTMLElement | null; + if (!scrollWrapper) return; + + let onScroll = () => { + setScrollOffset(scrollWrapper!.scrollTop); + }; + + scrollWrapper.addEventListener("scroll", onScroll); + + let resizeObserver = new ResizeObserver(calculatePositions); + resizeObserver.observe(scrollWrapper); + + let mutationObserver = new MutationObserver(calculatePositions); + mutationObserver.observe(scrollWrapper, { + childList: true, + subtree: true, + characterData: true, + }); + + return () => { + scrollWrapper!.removeEventListener("scroll", onScroll); + resizeObserver.disconnect(); + mutationObserver.disconnect(); + }; + }, [props.visible, calculatePositions]); + + if (!props.visible || footnotes.length === 0) return null; + + return ( +
+
+ {positions.map((fn) => ( + + deleteFootnoteFromBlock(fn.footnoteEntityID, fn.blockID, rep.rep) + : undefined + } + /> + + ))} +
+
+ ); +} + +function FootnoteSideItem(props: { + children: React.ReactNode; + footnoteEntityID: string; + top: number; + onResize: () => void; +}) { + let ref = useRef(null); + let [overflows, setOverflows] = useState(false); + + useEffect(() => { + let el = ref.current; + if (!el) return; + + let check = () => setOverflows(el!.scrollHeight > el!.clientHeight + 1); + check(); + + // Watch for content changes (text edits) + let mo = new MutationObserver(check); + mo.observe(el, { childList: true, subtree: true, characterData: true }); + + // Watch for size changes (expand/collapse on hover) and trigger reflow + let ro = new ResizeObserver(() => { + check(); + props.onResize(); + }); + ro.observe(el); + + return () => { + mo.disconnect(); + ro.disconnect(); + }; + }, [props.onResize]); + + return ( +
+ {props.children} +
+ ); +} diff --git a/components/Footnotes/deleteFootnoteFromBlock.ts b/components/Footnotes/deleteFootnoteFromBlock.ts new file mode 100644 index 00000000..ed97a0a2 --- /dev/null +++ b/components/Footnotes/deleteFootnoteFromBlock.ts @@ -0,0 +1,32 @@ +import { useEditorStates } from "src/state/useEditorState"; + +export function deleteFootnoteFromBlock( + footnoteEntityID: string, + blockID: string, + rep: any, +) { + if (!rep) return; + + let editorState = useEditorStates.getState().editorStates[blockID]; + if (editorState?.view) { + let view = editorState.view; + let { doc } = view.state; + let tr = view.state.tr; + let found = false; + doc.descendants((node, pos) => { + if ( + found || + node.type.name !== "footnote" || + node.attrs.footnoteEntityID !== footnoteEntityID + ) + return; + found = true; + tr.delete(pos, pos + node.nodeSize); + }); + if (found) { + view.dispatch(tr); + } + } + + rep.mutate.deleteFootnote({ footnoteEntityID, blockID }); +} diff --git a/components/Footnotes/usePageFootnotes.ts b/components/Footnotes/usePageFootnotes.ts new file mode 100644 index 00000000..3f5038e6 --- /dev/null +++ b/components/Footnotes/usePageFootnotes.ts @@ -0,0 +1,48 @@ +import { useReplicache } from "src/replicache"; +import { useSubscribe } from "src/replicache/useSubscribe"; +import { scanIndex } from "src/replicache/utils"; + +export type FootnoteInfo = { + footnoteEntityID: string; + blockID: string; + index: number; +}; + +export function usePageFootnotes(pageID: string) { + let rep = useReplicache(); + let data = useSubscribe( + rep?.rep, + async (tx) => { + let scan = scanIndex(tx); + let blocks = await scan.eav(pageID, "card/block"); + let sorted = blocks.toSorted((a, b) => + a.data.position > b.data.position ? 1 : -1, + ); + + let footnotes: FootnoteInfo[] = []; + let indexMap: Record = {}; + let idx = 1; + + for (let block of sorted) { + let blockFootnotes = await scan.eav(block.data.value, "block/footnote"); + let sortedFootnotes = blockFootnotes.toSorted((a, b) => + a.data.position > b.data.position ? 1 : -1, + ); + for (let fn of sortedFootnotes) { + footnotes.push({ + footnoteEntityID: fn.data.value, + blockID: block.data.value, + index: idx, + }); + indexMap[fn.data.value] = idx; + idx++; + } + } + + return { footnotes, indexMap }; + }, + { dependencies: [pageID] }, + ); + + return data || { footnotes: [], indexMap: {} as Record }; +} diff --git a/components/Pages/Page.tsx b/components/Pages/Page.tsx index 134f6ede..c6070fb6 100644 --- a/components/Pages/Page.tsx +++ b/components/Pages/Page.tsx @@ -17,6 +17,11 @@ import { PageOptions } from "./PageOptions"; import { CardThemeProvider } from "components/ThemeManager/ThemeProvider"; import { useDrawerOpen } from "app/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer"; import { usePreserveScroll } from "src/hooks/usePreserveScroll"; +import { usePageFootnotes } from "components/Footnotes/usePageFootnotes"; +import { FootnoteContext } from "components/Footnotes/FootnoteContext"; +import { FootnoteSection } from "components/Footnotes/FootnoteSection"; +import { FootnoteSideColumn } from "components/Footnotes/FootnoteSideColumn"; +import { FootnotePopover } from "components/Footnotes/FootnotePopover"; export function Page(props: { entityID: string; @@ -36,37 +41,54 @@ export function Page(props: { let pageType = useEntity(props.entityID, "page/type")?.data.value || "doc"; let drawerOpen = useDrawerOpen(props.entityID); + let footnoteData = usePageFootnotes(props.entityID); + let isRightmostPage = useUIState((s) => { + let pages = s.openPages; + if (pages.length === 0) return true; + return pages[pages.length - 1] === props.entityID; + }); + let sideColumnVisible = pageType === "doc" && !drawerOpen && isRightmostPage; + return ( - { - if (e.defaultPrevented) return; - if (rep) { - if (isFocused) return; - focusPage(props.entityID, rep); + + { + if (e.defaultPrevented) return; + if (rep) { + if (isFocused) return; + focusPage(props.entityID, rep); + } + }} + id={elementId.page(props.entityID).container} + drawerOpen={!!drawerOpen} + isFocused={isFocused} + fullPageScroll={props.fullPageScroll} + pageType={pageType} + pageOptions={ + } - }} - id={elementId.page(props.entityID).container} - drawerOpen={!!drawerOpen} - isFocused={isFocused} - fullPageScroll={props.fullPageScroll} - pageType={pageType} - pageOptions={ - - } - > - {props.first && pageType === "doc" && ( - <> - - - )} - - - + footnoteSideColumn={ + + } + > + {props.first && pageType === "doc" && ( + <> + + + )} + + + + + ); } @@ -75,6 +97,7 @@ export const PageWrapper = (props: { id: string; children: React.ReactNode; pageOptions?: React.ReactNode; + footnoteSideColumn?: React.ReactNode; fullPageScroll: boolean; isFocused?: boolean; onClickAction?: (e: React.MouseEvent) => void; @@ -132,6 +155,7 @@ export const PageWrapper = (props: {
{props.pageOptions} + {props.footnoteSideColumn} ); }; @@ -205,6 +229,7 @@ const DocContent = (props: { entityID: string }) => { /> ) : null} +
{/* we handle page bg in this sepate div so that we can apply an opacity the background image diff --git a/components/Toolbar/FootnoteButton.tsx b/components/Toolbar/FootnoteButton.tsx new file mode 100644 index 00000000..e71a6799 --- /dev/null +++ b/components/Toolbar/FootnoteButton.tsx @@ -0,0 +1,67 @@ +import { useEditorStates } from "src/state/useEditorState"; +import { useUIState } from "src/useUIState"; +import { useReplicache } from "src/replicache"; +import { useEntitySetContext } from "components/EntitySetProvider"; +import { insertFootnote } from "components/Blocks/TextBlock/insertFootnote"; +import { TooltipButton } from "components/Buttons"; +import { Props } from "components/Icons/Props"; + +export function FootnoteButton() { + let rep = useReplicache(); + let entity_set = useEntitySetContext(); + let focusedBlock = useUIState((s) => s.focusedEntity); + + return ( + Footnote
} + onMouseDown={async (e) => { + e.preventDefault(); + if (!focusedBlock || focusedBlock.entityType !== "block") return; + let editorState = + useEditorStates.getState().editorStates[focusedBlock.entityID]; + if (!editorState?.view || !rep.rep) return; + await insertFootnote( + editorState.view, + focusedBlock.entityID, + rep.rep, + entity_set.set, + ); + }} + > + + + ); +} + +function FootnoteIcon(props: Props) { + return ( + + + f + + + n + + + ); +} diff --git a/components/Toolbar/TextToolbar.tsx b/components/Toolbar/TextToolbar.tsx index b6663dd5..f9119142 100644 --- a/components/Toolbar/TextToolbar.tsx +++ b/components/Toolbar/TextToolbar.tsx @@ -8,6 +8,7 @@ import { HighlightButton } from "./HighlightToolbar"; import { ToolbarTypes } from "."; import { schema } from "components/Blocks/TextBlock/schema"; import { TextAlignmentButton } from "./TextAlignmentToolbar"; +import { FootnoteButton } from "./FootnoteButton"; import { Props } from "components/Icons/Props"; import { isMac } from "src/utils/isDevice"; @@ -80,6 +81,7 @@ export const TextToolbar = (props: { + ); }; diff --git a/lexicons/api/lexicons.ts b/lexicons/api/lexicons.ts index bb9d24b2..893df692 100644 --- a/lexicons/api/lexicons.ts +++ b/lexicons/api/lexicons.ts @@ -2027,6 +2027,7 @@ export const schemaDict = { 'lex:pub.leaflet.richtext.facet#id', 'lex:pub.leaflet.richtext.facet#bold', 'lex:pub.leaflet.richtext.facet#italic', + 'lex:pub.leaflet.richtext.facet#footnote', ], }, }, @@ -2128,6 +2129,26 @@ export const schemaDict = { required: [], properties: {}, }, + footnote: { + type: 'object', + description: 'Facet feature for a footnote reference', + required: ['footnoteId', 'contentPlaintext'], + properties: { + footnoteId: { + type: 'string', + }, + contentPlaintext: { + type: 'string', + }, + contentFacets: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:pub.leaflet.richtext.facet', + }, + }, + }, + }, }, }, PubLeafletThemeBackgroundImage: { diff --git a/lexicons/api/types/pub/leaflet/richtext/facet.ts b/lexicons/api/types/pub/leaflet/richtext/facet.ts index 466b1df0..dc8062c1 100644 --- a/lexicons/api/types/pub/leaflet/richtext/facet.ts +++ b/lexicons/api/types/pub/leaflet/richtext/facet.ts @@ -29,6 +29,7 @@ export interface Main { | $Typed | $Typed | $Typed + | $Typed | { $type: string } )[] } @@ -213,3 +214,21 @@ export function isItalic(v: V) { export function validateItalic(v: V) { return validate(v, id, hashItalic) } + +/** Facet feature for a footnote reference */ +export interface Footnote { + $type?: 'pub.leaflet.richtext.facet#footnote' + footnoteId: string + contentPlaintext: string + contentFacets?: Main[] +} + +const hashFootnote = 'footnote' + +export function isFootnote(v: V) { + return is$typed(v, id, hashFootnote) +} + +export function validateFootnote(v: V) { + return validate(v, id, hashFootnote) +} diff --git a/lexicons/pub/leaflet/richtext/facet.json b/lexicons/pub/leaflet/richtext/facet.json index 32c5ca9d..4822cc67 100644 --- a/lexicons/pub/leaflet/richtext/facet.json +++ b/lexicons/pub/leaflet/richtext/facet.json @@ -28,7 +28,8 @@ "#strikethrough", "#id", "#bold", - "#italic" + "#italic", + "#footnote" ] } } @@ -135,6 +136,26 @@ "description": "Facet feature for italic text", "required": [], "properties": {} + }, + "footnote": { + "type": "object", + "description": "Facet feature for a footnote reference", + "required": ["footnoteId", "contentPlaintext"], + "properties": { + "footnoteId": { + "type": "string" + }, + "contentPlaintext": { + "type": "string" + }, + "contentFacets": { + "type": "array", + "items": { + "type": "ref", + "ref": "#main" + } + } + } } } } \ No newline at end of file diff --git a/src/replicache/attributes.ts b/src/replicache/attributes.ts index 39b9012c..7d99eedc 100644 --- a/src/replicache/attributes.ts +++ b/src/replicache/attributes.ts @@ -107,6 +107,10 @@ const BlockAttributes = { type: "number", cardinality: "one", }, + "block/footnote": { + type: "ordered-reference", + cardinality: "many", + }, } as const; const MailboxAttributes = { diff --git a/src/replicache/mutations.ts b/src/replicache/mutations.ts index 1bf779be..5acfe3f9 100644 --- a/src/replicache/mutations.ts +++ b/src/replicache/mutations.ts @@ -720,6 +720,37 @@ const updatePublicationDraft: Mutation<{ }); }; +const createFootnote: Mutation<{ + footnoteEntityID: string; + blockID: string; + permission_set: string; + position: string; +}> = async (args, ctx) => { + await ctx.createEntity({ + entityID: args.footnoteEntityID, + permission_set: args.permission_set, + }); + await ctx.assertFact({ + entity: args.blockID, + attribute: "block/footnote", + data: { + type: "ordered-reference", + value: args.footnoteEntityID, + position: args.position, + }, + }); +}; + +const deleteFootnote: Mutation<{ + footnoteEntityID: string; + blockID: string; +}> = async (args, ctx) => { + let footnotes = await ctx.scanIndex.eav(args.blockID, "block/footnote"); + let fact = footnotes.find((f) => f.data.value === args.footnoteEntityID); + if (fact) await ctx.retractFact(fact.id); + await ctx.deleteEntity(args.footnoteEntityID); +}; + export const mutations = { retractAttribute, addBlock, @@ -743,4 +774,6 @@ export const mutations = { addPollOption, removePollOption, updatePublicationDraft, + createFootnote, + deleteFootnote, }; diff --git a/src/utils/deleteBlock.ts b/src/utils/deleteBlock.ts index 84f3e9e3..f166dcaf 100644 --- a/src/utils/deleteBlock.ts +++ b/src/utils/deleteBlock.ts @@ -113,6 +113,19 @@ export async function deleteBlock( // close the pages pagesToClose.forEach((page) => page && useUIState.getState().closePage(page)); + // Clean up footnotes from blocks being deleted + for (let entity of entities) { + let footnotes = await rep.query((tx) => + scanIndex(tx).eav(entity, "block/footnote"), + ); + for (let fn of footnotes) { + await rep.mutate.deleteFootnote({ + footnoteEntityID: fn.data.value, + blockID: entity, + }); + } + } + await Promise.all( entities.map((entity) => rep?.mutate.removeBlock({ diff --git a/src/utils/yjsFragmentToString.ts b/src/utils/yjsFragmentToString.ts index a4738061..d4311078 100644 --- a/src/utils/yjsFragmentToString.ts +++ b/src/utils/yjsFragmentToString.ts @@ -25,6 +25,10 @@ export function YJSFragmentToString( if (node.nodeName === "didMention" || node.nodeName === "atMention") { return node.getAttribute("text") || ""; } + // Handle footnote nodes - emit placeholder + if (node.nodeName === "footnote") { + return "*"; + } return node .toArray() .map((f) => YJSFragmentToString(f))