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(
+ ,
+ );
+ }
+ 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) => (
+
+ ))}
+
+
+ );
+}
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 (
+
+ );
+}
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))