From d5eef5b342e351f1548865ca918ddf401ea6652a Mon Sep 17 00:00:00 2001
From: Jared Pereira
Date: Fri, 6 Mar 2026 15:57:22 -0500
Subject: [PATCH] Add footnotes!
Squashed commit of the following:
commit 0f22f24c5ff5ed98ab139fc609632bfef20fd280
Merge: 26876031 66f9d441
Author: Jared Pereira
Date: Fri Mar 6 15:55:34 2026 -0500
Merge branch 'main' into feature/footnotes
commit 268760316c02f10d545f7f81b9438cc76981bb7c
Author: Jared Pereira
Date: Fri Mar 6 15:38:32 2026 -0500
open footnote popover
commit 5add9002932f24b2cb3e88a79415359e5bfdc1aa
Author: Jared Pereira
Date: Fri Mar 6 15:29:28 2026 -0500
try mask-image css
commit 10f35a1361dfb4672b03391b91090b368c5abbf9
Author: Jared Pereira
Date: Fri Mar 6 15:16:33 2026 -0500
make footnotes text sm
commit 3ae07878e7284b1112d177c4d787d27e1f2726f4
Author: Jared Pereira
Date: Fri Mar 6 14:31:46 2026 -0500
fix the type errors
commit 2aa857ba975fa5df90629984550d3aa7ef47d402
Author: Jared Pereira
Date: Fri Mar 6 11:45:01 2026 -0500
fix footnote rendering and counts
commit 212fabd22c77d12e71530403eb6b5bbbdbef1ae3
Author: celine
Date: Thu Mar 5 22:24:39 2026 -0500
autofocus editor when footnote is selecter
commit ce10ee81ff666d1c5f33e927719d0c5a840627ae
Author: celine
Date: Thu Mar 5 21:56:21 2026 -0500
some styling updates
commit 4a8480f35afe12c2703f07a19579e06ec3f509fb
Author: Jared Pereira
Date: Thu Mar 5 18:58:14 2026 -0500
add toolbar stuff
commit bf1a2f50bb31336fefd67d47d11e670aeabf9da4
Author: Jared Pereira
Date: Thu Mar 5 18:06:11 2026 -0500
fix popover number and threshold
commit aae6fae776da6570a26e6de12fab119ef8510bc7
Author: Jared Pereira
Date: Thu Mar 5 16:22:40 2026 -0500
share footnote item layout
commit 0f843a5a899eacd98d17f11412d07ca91b74841e
Author: Jared Pereira
Date: Thu Mar 5 16:15:25 2026 -0500
share editor logic between footnote and textblock
commit 16f8052758e6aae9157ab97014509ef010eb717f
Author: Jared Pereira
Date: Thu Mar 5 16:15:17 2026 -0500
render footnotes properly in published page
commit 713d14b48eeb5a2ceb44a71099b3d912b9f3ba6c
Author: Jared Pereira
Date: Thu Mar 5 15:53:39 2026 -0500
add footnotes
---
actions/publishToPublication.ts | 41 ++-
app/[leaflet_id]/Footer.tsx | 12 +
app/globals.css | 53 ++++
.../[rkey]/Blocks/BaseTextBlock.tsx | 3 +
.../[publication]/[rkey]/Blocks/TextBlock.tsx | 3 +-
.../[rkey]/Blocks/TextBlockCore.tsx | 24 ++
.../Footnotes/FootnoteCollectorContext.tsx | 56 +++++
.../Footnotes/PublishedFootnotePopover.tsx | 163 ++++++++++++
.../Footnotes/PublishedFootnoteSideColumn.tsx | 56 +++++
.../[rkey]/Footnotes/PublishedFootnotes.tsx | 120 +++++++++
.../[rkey]/LinearDocumentPage.tsx | 17 ++
.../[publication]/[rkey]/PostContent.tsx | 10 +
.../[did]/[publication]/[rkey]/PostPages.tsx | 15 +-
.../[rkey]/StaticPostContent.tsx | 2 +-
.../Blocks/TextBlock/RenderYJSFragment.tsx | 15 ++
components/Blocks/TextBlock/index.tsx | 14 ++
components/Blocks/TextBlock/inputRules.ts | 18 ++
components/Blocks/TextBlock/insertFootnote.ts | 43 ++++
.../Blocks/TextBlock/mountProsemirror.ts | 138 ++++++++--
components/Blocks/TextBlock/schema.ts | 35 +++
components/Blocks/index.tsx | 19 +-
components/DesktopFooter.tsx | 15 ++
components/Footnotes/FootnoteContext.tsx | 18 ++
components/Footnotes/FootnoteEditor.tsx | 235 ++++++++++++++++++
components/Footnotes/FootnoteItemLayout.tsx | 58 +++++
components/Footnotes/FootnotePopover.tsx | 152 +++++++++++
components/Footnotes/FootnoteSection.tsx | 38 +++
components/Footnotes/FootnoteSideColumn.tsx | 63 +++++
.../Footnotes/FootnoteSideColumnLayout.tsx | 178 +++++++++++++
.../Footnotes/deleteFootnoteFromBlock.ts | 32 +++
components/Footnotes/usePageFootnotes.ts | 61 +++++
components/Pages/Page.tsx | 84 ++++---
components/Toolbar/FootnoteButton.tsx | 57 +++++
components/Toolbar/FootnoteTextToolbar.tsx | 70 ++++++
components/Toolbar/FootnoteToolbarWrapper.tsx | 77 ++++++
components/Toolbar/TextToolbar.tsx | 2 +
lexicons/api/lexicons.ts | 21 ++
.../api/types/pub/leaflet/richtext/facet.ts | 19 ++
lexicons/pub/leaflet/richtext/facet.json | 23 +-
src/replicache/attributes.ts | 4 +
src/replicache/mutations.ts | 33 +++
src/useUIState.ts | 2 +
src/utils/deleteBlock.ts | 13 +
src/utils/yjsFragmentToString.ts | 4 +
44 files changed, 2050 insertions(+), 66 deletions(-)
create mode 100644 app/lish/[did]/[publication]/[rkey]/Footnotes/FootnoteCollectorContext.tsx
create mode 100644 app/lish/[did]/[publication]/[rkey]/Footnotes/PublishedFootnotePopover.tsx
create mode 100644 app/lish/[did]/[publication]/[rkey]/Footnotes/PublishedFootnoteSideColumn.tsx
create mode 100644 app/lish/[did]/[publication]/[rkey]/Footnotes/PublishedFootnotes.tsx
create mode 100644 components/Blocks/TextBlock/insertFootnote.ts
create mode 100644 components/Footnotes/FootnoteContext.tsx
create mode 100644 components/Footnotes/FootnoteEditor.tsx
create mode 100644 components/Footnotes/FootnoteItemLayout.tsx
create mode 100644 components/Footnotes/FootnotePopover.tsx
create mode 100644 components/Footnotes/FootnoteSection.tsx
create mode 100644 components/Footnotes/FootnoteSideColumn.tsx
create mode 100644 components/Footnotes/FootnoteSideColumnLayout.tsx
create mode 100644 components/Footnotes/deleteFootnoteFromBlock.ts
create mode 100644 components/Footnotes/usePageFootnotes.ts
create mode 100644 components/Toolbar/FootnoteButton.tsx
create mode 100644 components/Toolbar/FootnoteTextToolbar.tsx
create mode 100644 components/Toolbar/FootnoteToolbarWrapper.tsx
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/[leaflet_id]/Footer.tsx b/app/[leaflet_id]/Footer.tsx
index ad063f02..f8ff45fa 100644
--- a/app/[leaflet_id]/Footer.tsx
+++ b/app/[leaflet_id]/Footer.tsx
@@ -4,6 +4,7 @@ import { Footer } from "components/ActionBar/Footer";
import { Media } from "components/Media";
import { ThemePopover } from "components/ThemeManager/ThemeSetter";
import { Toolbar } from "components/Toolbar";
+import { FootnoteToolbar } from "components/Toolbar/FootnoteToolbarWrapper";
import { ShareOptions } from "app/[leaflet_id]/actions/ShareOptions";
import { HomeButton } from "app/[leaflet_id]/actions/HomeButton";
import { PublishButton } from "./actions/PublishButton";
@@ -56,6 +57,17 @@ export function LeafletFooter(props: { entityID: string }) {
blockType={blockType}
/>
+ ) : focusedBlock &&
+ focusedBlock.entityType === "footnote" &&
+ entity_set.permissions.write ? (
+ {
+ if (e.currentTarget === e.target) e.preventDefault();
+ }}
+ >
+
+
) : entity_set.permissions.write ? (
);
@@ -403,6 +410,7 @@ export let Block = ({
index={index}
preview={preview}
pageId={pageId}
+ footnoteIndexMap={footnoteIndexMap}
/>
);
@@ -414,6 +422,7 @@ export let Block = ({
index={index}
preview={preview}
pageId={pageId}
+ footnoteIndexMap={footnoteIndexMap}
/>
);
@@ -426,6 +435,7 @@ export let Block = ({
index={index}
preview={preview}
pageId={pageId}
+ footnoteIndexMap={footnoteIndexMap}
/>
);
diff --git a/app/lish/[did]/[publication]/[rkey]/PostPages.tsx b/app/lish/[did]/[publication]/[rkey]/PostPages.tsx
index 972ef073..ccf5bea2 100644
--- a/app/lish/[did]/[publication]/[rkey]/PostPages.tsx
+++ b/app/lish/[did]/[publication]/[rkey]/PostPages.tsx
@@ -185,6 +185,7 @@ export type SharedPageProps = {
pageId?: string;
pageOptions?: React.ReactNode;
allPages: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[];
+ hasContentToRight?: boolean;
};
// Component that renders either Canvas or Linear page based on page type
@@ -286,7 +287,13 @@ export function PostPages({
<>
{!sharedProps.fullPageScroll && }
-
+ 0 || !!(drawer && !drawer.pageId)
+ }
+ />
{drawer && !drawer.pageId && (
)}
- {openPageIds.map((openPage) => {
+ {openPageIds.map((openPage, openPageIndex) => {
const pageKey = getPageKey(openPage);
// Handle thread pages
@@ -372,6 +379,10 @@ export function PostPages({
{...sharedProps}
fullPageScroll={false}
pageId={page.id}
+ hasContentToRight={
+ openPageIndex < openPageIds.length - 1 ||
+ !!(drawer && drawer.pageId === page.id)
+ }
pageOptions={
closePage(openPage)}
diff --git a/app/lish/[did]/[publication]/[rkey]/StaticPostContent.tsx b/app/lish/[did]/[publication]/[rkey]/StaticPostContent.tsx
index 494a2f3e..42238f77 100644
--- a/app/lish/[did]/[publication]/[rkey]/StaticPostContent.tsx
+++ b/app/lish/[did]/[publication]/[rkey]/StaticPostContent.tsx
@@ -29,7 +29,7 @@ export function StaticPostContent({
did: string;
}) {
return (
-
+
{blocks.map((b, index) => {
return
;
})}
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/index.tsx b/components/Blocks/TextBlock/index.tsx
index 71574e9a..fd6a6be5 100644
--- a/components/Blocks/TextBlock/index.tsx
+++ b/components/Blocks/TextBlock/index.tsx
@@ -25,6 +25,7 @@ import { useLeafletPublicationData } from "components/PageSWRDataProvider";
import { DotLoader } from "components/utils/DotLoader";
import { useMountProsemirror } from "./mountProsemirror";
import { schema } from "./schema";
+import { useFootnotePopoverStore } from "components/Footnotes/FootnotePopover";
import { blockTextSize } from "src/utils/blockTextSize";
import { Mention, MentionAutocomplete } from "components/Mention";
@@ -171,6 +172,19 @@ export function RenderedTextBlock(props: {
wordBreak: "break-word",
...(props.type === "heading" ? { fontSize: headingFontSize[headingLevel?.data.value || 1] } : {}),
}}
+ onClick={(e) => {
+ let target = e.target as HTMLElement;
+ let footnoteRef = target.closest(".footnote-ref") as HTMLElement | null;
+ if (!footnoteRef) return;
+ let footnoteID = footnoteRef.dataset.footnoteId;
+ if (!footnoteID) return;
+ let store = useFootnotePopoverStore.getState();
+ if (store.activeFootnoteID === footnoteID) {
+ store.close();
+ } else {
+ store.open(footnoteID, footnoteRef);
+ }
+ }}
className={`
${alignmentClass}
${props.type === "blockquote" ? (props.previousBlock?.type === "blockquote" ? `blockquote pt-3 ` : "blockquote") : ""}
diff --git a/components/Blocks/TextBlock/inputRules.ts b/components/Blocks/TextBlock/inputRules.ts
index 90673c91..44814605 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..6a5af3c9 100644
--- a/components/Blocks/TextBlock/mountProsemirror.ts
+++ b/components/Blocks/TextBlock/mountProsemirror.ts
@@ -1,5 +1,5 @@
import { useLayoutEffect, useRef, useEffect, useState } from "react";
-import { EditorState } from "prosemirror-state";
+import { EditorState, Transaction } from "prosemirror-state";
import { EditorView } from "prosemirror-view";
import { baseKeymap } from "prosemirror-commands";
import { keymap } from "prosemirror-keymap";
@@ -10,6 +10,7 @@ import { Replicache } from "replicache";
import { produce } from "immer";
import { schema } from "./schema";
+import { UndoManager } from "src/undoManager";
import { TextBlockKeymap } from "./keymap";
import { inputrules } from "./inputRules";
import { highlightSelectionPlugin } from "./plugins";
@@ -24,6 +25,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 +83,49 @@ 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 or canvas, show popover
+ let isDesktop = window.matchMedia("(min-width: 1280px)").matches;
+ let isCanvas = propsRef.current.pageType === "canvas";
+ if (!isDesktop || isCanvas) {
+ 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(
@@ -143,36 +188,49 @@ export function useMountProsemirror({
useEditorStates.setState((s) => {
let oldEditorState = this.state;
let newState = this.state.apply(tr);
- let addToHistory = tr.getMeta("addToHistory");
- let isBulkOp = tr.getMeta("bulkOp");
let docHasChanges = tr.steps.length !== 0 || tr.docChanged;
- // Handle undo/redo history with timeout-based grouping
- if (addToHistory !== false && docHasChanges) {
- if (actionTimeout.current) window.clearTimeout(actionTimeout.current);
- else if (!isBulkOp) rep.undoManager.startGroup();
-
- if (!isBulkOp) {
- actionTimeout.current = window.setTimeout(() => {
- rep.undoManager.endGroup();
- actionTimeout.current = null;
- }, 200);
+ // 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,
+ });
+ }
}
+ }
- let setState = (s: EditorState) => () =>
- useEditorStates.setState(
- produce((draft) => {
- let view = draft.editorStates[entityID]?.view;
- if (!view?.hasFocus() && !isBulkOp) view?.focus();
- draft.editorStates[entityID]!.editor = s;
- }),
- );
+ // Handle undo/redo history with timeout-based grouping
+ let isBulkOp = tr.getMeta("bulkOp");
+ let setState = (s: EditorState) => () =>
+ useEditorStates.setState(
+ produce((draft) => {
+ let view = draft.editorStates[entityID]?.view;
+ if (!view?.hasFocus() && !isBulkOp) view?.focus();
+ draft.editorStates[entityID]!.editor = s;
+ }),
+ );
- rep.undoManager.add({
- redo: setState(newState),
- undo: setState(oldEditorState),
- });
- }
+ trackUndoRedo(
+ tr,
+ rep.undoManager,
+ actionTimeout,
+ setState(oldEditorState),
+ setState(newState),
+ );
return {
editorStates: {
@@ -191,7 +249,33 @@ export function useMountProsemirror({
return { mountRef, actionTimeout };
}
-function useYJSValue(entityID: string) {
+export function trackUndoRedo(
+ tr: Transaction,
+ undoManager: UndoManager,
+ actionTimeout: { current: number | null },
+ undo: () => void,
+ redo: () => void,
+) {
+ let addToHistory = tr.getMeta("addToHistory");
+ let isBulkOp = tr.getMeta("bulkOp");
+ let docHasChanges = tr.steps.length !== 0 || tr.docChanged;
+
+ if (addToHistory !== false && docHasChanges) {
+ if (actionTimeout.current) window.clearTimeout(actionTimeout.current);
+ else if (!isBulkOp) undoManager.startGroup();
+
+ if (!isBulkOp) {
+ actionTimeout.current = window.setTimeout(() => {
+ undoManager.endGroup();
+ actionTimeout.current = null;
+ }, 200);
+ }
+
+ undoManager.add({ undo, redo });
+ }
+}
+
+export function useYJSValue(entityID: string) {
const [ydoc] = useState(new Y.Doc());
const docStateFromReplicache = useEntity(entityID, "block/text");
let rep = useReplicache();
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/Blocks/index.tsx b/components/Blocks/index.tsx
index f5d49802..b37b29c1 100644
--- a/components/Blocks/index.tsx
+++ b/components/Blocks/index.tsx
@@ -14,9 +14,10 @@ import { generateKeyBetween } from "fractional-indexing";
import { v7 } from "uuid";
import { Block } from "./Block";
-import { useEffect } from "react";
+import { useEffect, useState } from "react";
import { addShortcut } from "src/shortcuts";
import { useHandleDrop } from "./useHandleDrop";
+import { useFootnoteContext } from "components/Footnotes/FootnoteContext";
export function Blocks(props: { entityID: string }) {
let rep = useReplicache();
@@ -93,9 +94,17 @@ export function Blocks(props: { entityID: string }) {
),
);
+ let { footnotes } = useFootnoteContext();
+
+ let [areFootnotes, setAreFootnotes] = useState(false);
+
+ useEffect(() => {
+ setAreFootnotes(footnotes.length > 0);
+ }, [footnotes.length]);
+
return (
{
if (!permissions.write) return;
if (useUIState.getState().selectedBlocks.length > 1) return;
@@ -167,6 +176,7 @@ export function Blocks(props: { entityID: string }) {
lastVisibleBlock={lastVisibleBlock || undefined}
lastRootBlock={lastRootBlock || undefined}
entityID={props.entityID}
+ areFootnotes={areFootnotes}
/>
);
@@ -226,6 +236,7 @@ const BlockListBottom = (props: {
lastRootBlock: Block | undefined;
lastVisibleBlock: Block | undefined;
entityID: string;
+ areFootnotes: boolean;
}) => {
let { rep } = useReplicache();
let entity_set = useEntitySetContext();
@@ -236,9 +247,11 @@ const BlockListBottom = (props: {
});
if (!entity_set.permissions.write) return;
+ if (props.areFootnotes) return;
+
return (
{
let newEntityID = v7();
if (
diff --git a/components/DesktopFooter.tsx b/components/DesktopFooter.tsx
index f8937218..976b47b7 100644
--- a/components/DesktopFooter.tsx
+++ b/components/DesktopFooter.tsx
@@ -2,6 +2,7 @@
import { useUIState } from "src/useUIState";
import { Media } from "./Media";
import { Toolbar } from "./Toolbar";
+import { FootnoteToolbar } from "./Toolbar/FootnoteToolbarWrapper";
import { useEntitySetContext } from "./EntitySetProvider";
import { focusBlock } from "src/utils/focusBlock";
import { hasBlockToolbar } from "app/[leaflet_id]/Footer";
@@ -18,6 +19,10 @@ export function DesktopPageFooter(props: { pageID: string }) {
let blockType = useEntity(focusedEntity?.entityID || null, "block/type")?.data
.value;
+ let isFootnoteFocused =
+ focusedEntity?.entityType === "footnote" &&
+ focusedEntity.parent === props.pageID;
+
return (
)}
+ {isFootnoteFocused && entity_set.permissions.write && (
+ {
+ if (e.currentTarget === e.target) e.preventDefault();
+ }}
+ >
+
+
+ )}
);
}
diff --git a/components/Footnotes/FootnoteContext.tsx b/components/Footnotes/FootnoteContext.tsx
new file mode 100644
index 00000000..918c7461
--- /dev/null
+++ b/components/Footnotes/FootnoteContext.tsx
@@ -0,0 +1,18 @@
+import { createContext, useContext } from "react";
+import type { FootnoteInfo } from "./usePageFootnotes";
+
+type FootnoteContextValue = {
+ pageID: string;
+ footnotes: FootnoteInfo[];
+ indexMap: Record;
+};
+
+export const FootnoteContext = createContext({
+ pageID: "",
+ 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..a2a2d30a
--- /dev/null
+++ b/components/Footnotes/FootnoteEditor.tsx
@@ -0,0 +1,235 @@
+import { useLayoutEffect, useRef } from "react";
+import { EditorState, TextSelection } 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 { schema } from "components/Blocks/TextBlock/schema";
+import { useReplicache } from "src/replicache";
+import { autolink } from "components/Blocks/TextBlock/autolink-plugin";
+import { betterIsUrl } from "src/utils/isURL";
+import {
+ useYJSValue,
+ trackUndoRedo,
+} from "components/Blocks/TextBlock/mountProsemirror";
+import { DeleteTiny } from "components/Icons/DeleteTiny";
+import { FootnoteItemLayout } from "./FootnoteItemLayout";
+import { useEditorStates } from "src/state/useEditorState";
+import { useUIState } from "src/useUIState";
+import { useFootnoteContext } from "./FootnoteContext";
+
+export function FootnoteEditor(props: {
+ footnoteEntityID: string;
+ index: number;
+ editable: boolean;
+ onDelete?: () => void;
+ autoFocus?: boolean;
+}) {
+ let mountRef = useRef(null);
+ let rep = useReplicache();
+ let value = useYJSValue(props.footnoteEntityID);
+ let actionTimeout = useRef(null);
+ let { pageID } = useFootnoteContext();
+
+ 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,
+ handlePaste: (view, e) => {
+ let text = e.clipboardData?.getData("text");
+ if (text && betterIsUrl(text)) {
+ let selection = view.state.selection as TextSelection;
+ let tr = view.state.tr;
+ let { from, to } = selection;
+ if (selection.empty) {
+ tr.insertText(text, selection.from);
+ tr.addMark(
+ from,
+ from + text.length,
+ schema.marks.link.create({ href: text }),
+ );
+ } else {
+ tr.addMark(from, to, schema.marks.link.create({ href: text }));
+ }
+ view.dispatch(tr);
+ return true;
+ }
+ },
+ handleClickOn: (_view, _pos, node, _nodePos, _event, direct) => {
+ if (!direct) return;
+ if (node.nodeSize - 2 <= _pos) return;
+ const nodeAt1 = node.nodeAt(_pos - 1);
+ const nodeAt2 = node.nodeAt(Math.max(_pos - 2, 0));
+ let linkMark =
+ nodeAt1?.marks.find((f) => f.type === schema.marks.link) ||
+ nodeAt2?.marks.find((f) => f.type === schema.marks.link);
+ if (linkMark) {
+ window.open(linkMark.attrs.href, "_blank");
+ return;
+ }
+ },
+ dispatchTransaction(this: EditorView, tr) {
+ let oldState = this.state;
+ let newState = this.state.apply(tr);
+ this.updateState(newState);
+
+ useEditorStates.setState((s) => ({
+ editorStates: {
+ ...s.editorStates,
+ [props.footnoteEntityID]: {
+ editor: newState,
+ view: this,
+ },
+ },
+ }));
+
+ trackUndoRedo(
+ tr,
+ rep.undoManager,
+ actionTimeout,
+ () => {
+ this.focus();
+ this.updateState(oldState);
+ },
+ () => {
+ this.focus();
+ this.updateState(newState);
+ },
+ );
+ },
+ },
+ );
+
+ // Register editor state
+ useEditorStates.setState((s) => ({
+ editorStates: {
+ ...s.editorStates,
+ [props.footnoteEntityID]: {
+ editor: view.state,
+ view,
+ },
+ },
+ }));
+
+ // Subscribe to external state changes (e.g. link toolbar)
+ let unsubscribe = useEditorStates.subscribe((s) => {
+ let editorState = s.editorStates[props.footnoteEntityID];
+ if (editorState?.editor)
+ editorState.view?.updateState(editorState.editor);
+ });
+
+ // Set focusedEntity on focus
+ let handleFocus = () => {
+ useUIState.setState({
+ focusedEntity: {
+ entityType: "footnote",
+ entityID: props.footnoteEntityID,
+ parent: pageID,
+ },
+ });
+ };
+ view.dom.addEventListener("focus", handleFocus);
+
+ if (props.autoFocus) {
+ setTimeout(() => view.focus(), 50);
+ }
+
+ return () => {
+ unsubscribe();
+ view.dom.removeEventListener("focus", handleFocus);
+ view.destroy();
+ useEditorStates.setState((s) => {
+ let { [props.footnoteEntityID]: _, ...rest } = s.editorStates;
+ return { editorStates: rest };
+ });
+ };
+ }, [
+ props.footnoteEntityID,
+ value,
+ props.editable,
+ props.autoFocus,
+ rep.undoManager,
+ pageID,
+ ]);
+
+ return (
+
+
{
+ let pm = mountRef.current?.querySelector(
+ ".ProseMirror",
+ ) as HTMLElement | null;
+ if (pm) {
+ pm.focus();
+ }
+ }}
+ trailing={
+ props.editable && props.onDelete ? (
+
+ ) : undefined
+ }
+ >
+
+
+
+ );
+}
+
+function FootnoteDeleteButton(props: {
+ footnoteEntityID: string;
+ onDelete: () => void;
+}) {
+ let isActive = useUIState(
+ (s) =>
+ s.focusedEntity?.entityType === "footnote" &&
+ s.focusedEntity.entityID === props.footnoteEntityID,
+ );
+
+ return (
+
+ );
+}
diff --git a/components/Footnotes/FootnoteItemLayout.tsx b/components/Footnotes/FootnoteItemLayout.tsx
new file mode 100644
index 00000000..72c60528
--- /dev/null
+++ b/components/Footnotes/FootnoteItemLayout.tsx
@@ -0,0 +1,58 @@
+import { ReactNode } from "react";
+
+export function FootnoteItemLayout(props: {
+ index: number;
+ indexAction?: () => void;
+ indexHref?: string;
+ children: ReactNode;
+ trailing?: ReactNode;
+ id?: string;
+ className?: string;
+}) {
+ let indexClassName =
+ "text-tertiary font-medium shrink-0 text-sm leading-normal no-underline hover:underline cursor-pointer";
+
+ let indexContent = <>{props.index}.>;
+
+ return (
+
+ {props.indexHref ? (
+
+ {indexContent}
+
+ ) : (
+
+ )}
+
+ {props.children}
+
+ {props.trailing}
+
+ );
+}
+
+export function FootnoteSectionLayout(props: {
+ children: ReactNode;
+ className?: string;
+}) {
+ return (
+
+ );
+}
diff --git a/components/Footnotes/FootnotePopover.tsx b/components/Footnotes/FootnotePopover.tsx
new file mode 100644
index 00000000..f839517c
--- /dev/null
+++ b/components/Footnotes/FootnotePopover.tsx
@@ -0,0 +1,152 @@
+"use client";
+
+import { useEffect, useRef, useState, useCallback, useMemo } 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,
+ );
+
+ // Compute the displayed index from DOM order (matching CSS counters)
+ // rather than the data model order, which may differ if footnotes
+ // were inserted out of order within a block.
+ let displayIndex = useMemo(() => {
+ if (!anchorElement || !footnote) return footnote?.index ?? 0;
+ let container = anchorElement.closest('.footnote-scope');
+ if (!container) return footnote.index;
+ let allRefs = Array.from(container.querySelectorAll(".footnote-ref"));
+ let pos = allRefs.indexOf(anchorElement);
+ return pos >= 0 ? pos + 1 : footnote.index;
+ }, [anchorElement, footnote]);
+
+ 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..b230d150
--- /dev/null
+++ b/components/Footnotes/FootnoteSection.tsx
@@ -0,0 +1,38 @@
+import { useFootnoteContext } from "./FootnoteContext";
+import { FootnoteEditor } from "./FootnoteEditor";
+import { useReplicache } from "src/replicache";
+import { useEntitySetContext } from "components/EntitySetProvider";
+import { deleteFootnoteFromBlock } from "./deleteFootnoteFromBlock";
+import { FootnoteSectionLayout } from "./FootnoteItemLayout";
+
+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..f73fac18
--- /dev/null
+++ b/components/Footnotes/FootnoteSideColumn.tsx
@@ -0,0 +1,63 @@
+import { 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";
+import { FootnoteSideColumnLayout } from "./FootnoteSideColumnLayout";
+
+type EditorFootnoteItem = {
+ id: string;
+ index: number;
+ footnoteEntityID: string;
+ blockID: string;
+};
+
+export function FootnoteSideColumn(props: {
+ pageEntityID: string;
+ visible: boolean;
+ fullPageScroll?: boolean;
+}) {
+ let { footnotes } = useFootnoteContext();
+ let { permissions } = useEntitySetContext();
+ let rep = useReplicache();
+
+ let items: EditorFootnoteItem[] = footnotes.map((fn) => ({
+ id: fn.footnoteEntityID,
+ index: fn.index,
+ footnoteEntityID: fn.footnoteEntityID,
+ blockID: fn.blockID,
+ }));
+
+ let getAnchorSelector = useCallback(
+ (item: EditorFootnoteItem) =>
+ `.footnote-ref[data-footnote-id="${item.id}"]`,
+ [],
+ );
+
+ let renderItem = useCallback(
+ (item: EditorFootnoteItem & { top: number }) => (
+ deleteFootnoteFromBlock(item.footnoteEntityID, item.blockID, rep.rep)
+ : undefined
+ }
+ />
+ ),
+ [permissions.write, rep.rep],
+ );
+
+ return (
+
+ );
+}
diff --git a/components/Footnotes/FootnoteSideColumnLayout.tsx b/components/Footnotes/FootnoteSideColumnLayout.tsx
new file mode 100644
index 00000000..ae9007ba
--- /dev/null
+++ b/components/Footnotes/FootnoteSideColumnLayout.tsx
@@ -0,0 +1,178 @@
+"use client";
+
+import { useEffect, useRef, useState, useCallback, ReactNode } from "react";
+import { useUIState } from "src/useUIState";
+
+export type FootnoteSideItem = {
+ id: string;
+ index: number;
+};
+
+const GAP = 4;
+
+export function FootnoteSideColumnLayout(props: {
+ items: T[];
+ visible: boolean;
+ fullPageScroll?: boolean;
+ getAnchorSelector: (item: T) => string;
+ renderItem: (item: T & { top: number }) => ReactNode;
+}) {
+ let containerRef = useRef(null);
+ let innerRef = useRef(null);
+ let [positions, setPositions] = useState<(T & { top: number })[]>([]);
+ let [scrollOffset, setScrollOffset] = useState(0);
+
+ let calculatePositions = useCallback(() => {
+ let container = containerRef.current;
+ let inner = innerRef.current;
+ if (!container || !inner || props.items.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);
+
+ let measurements: (T & { anchorTop: number; height: number })[] = [];
+
+ for (let item of props.items) {
+ let supEl = scrollWrapper.querySelector(
+ props.getAnchorSelector(item),
+ ) as HTMLElement | null;
+ if (!supEl) continue;
+
+ let supRect = supEl.getBoundingClientRect();
+ let anchorTop = supRect.top - scrollWrapperRect.top + scrollTop;
+
+ let itemEl = inner.querySelector(
+ `[data-footnote-side-id="${item.id}"]`,
+ ) as HTMLElement | null;
+ let height = itemEl ? itemEl.offsetHeight : 54;
+
+ measurements.push({ ...item, anchorTop, height });
+ }
+
+ let resolved: (T & { top: number })[] = [];
+ let nextAvailableTop = 0;
+ for (let m of measurements) {
+ let top = Math.max(m.anchorTop, nextAvailableTop);
+ resolved.push({
+ ...m,
+ top,
+ });
+ nextAvailableTop = top + m.height + GAP;
+ }
+
+ setPositions(resolved);
+ }, [props.items, props.getAnchorSelector]);
+
+ 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 || props.items.length === 0) return null;
+
+ return (
+
+
+ {positions.map((item) => (
+
+ {props.renderItem(item)}
+
+ ))}
+
+
+ );
+}
+
+function SideItem(props: {
+ children: ReactNode;
+ id: string;
+ top: number;
+ onResize: () => void;
+}) {
+ let ref = useRef(null);
+ let [overflows, setOverflows] = useState(false);
+ let isFocused = useUIState(
+ (s) =>
+ s.focusedEntity?.entityType === "footnote" &&
+ s.focusedEntity.entityID === props.id,
+ );
+
+ useEffect(() => {
+ let el = ref.current;
+ if (!el) return;
+
+ let check = () => setOverflows(el!.scrollHeight > el!.clientHeight + 1);
+ check();
+
+ let ro = new ResizeObserver(() => {
+ check();
+ props.onResize();
+ });
+ ro.observe(el);
+
+ let mo = new MutationObserver(check);
+ mo.observe(el, { childList: true, subtree: true, characterData: true });
+
+ return () => {
+ ro.disconnect();
+ mo.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..b2889a9a
--- /dev/null
+++ b/components/Footnotes/usePageFootnotes.ts
@@ -0,0 +1,61 @@
+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 cardBlocks = await scan.eav(pageID, "card/block");
+ let canvasBlocks = await scan.eav(pageID, "canvas/block");
+
+ let sortedCardBlocks = cardBlocks
+ .map((b) => ({ value: b.data.value, position: b.data.position }))
+ .toSorted((a, b) => (a.position > b.position ? 1 : -1));
+
+ let sortedCanvasBlocks = canvasBlocks
+ .map((b) => ({ value: b.data.value, position: b.data.position }))
+ .toSorted((a, b) => {
+ if (a.position.y === b.position.y) return a.position.x - b.position.x;
+ return a.position.y - b.position.y;
+ });
+
+ let sorted = [...sortedCardBlocks, ...sortedCanvasBlocks];
+
+ let footnotes: FootnoteInfo[] = [];
+ let indexMap: Record = {};
+ let idx = 1;
+
+ for (let block of sorted) {
+ let blockFootnotes = await scan.eav(block.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.value,
+ index: idx,
+ });
+ indexMap[fn.data.value] = idx;
+ idx++;
+ }
+ }
+
+ return { pageID, footnotes, indexMap };
+ },
+ { dependencies: [pageID] },
+ );
+
+ return (
+ data || { pageID, footnotes: [], indexMap: {} as Record }
+ );
+}
diff --git a/components/Pages/Page.tsx b/components/Pages/Page.tsx
index 134f6ede..30f74696 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,55 @@ 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 +98,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;
@@ -123,7 +147,7 @@ export const PageWrapper = (props: {
`}
>
@@ -132,6 +156,7 @@ export const PageWrapper = (props: {
{props.pageOptions}
+ {props.footnoteSideColumn}
);
};
@@ -205,6 +230,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..0c4e09f4
--- /dev/null
+++ b/components/Toolbar/FootnoteButton.tsx
@@ -0,0 +1,57 @@
+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";
+import { ToolbarButton } from ".";
+
+export function FootnoteButton() {
+ let rep = useReplicache();
+ let entity_set = useEntitySetContext();
+ let focusedBlock = useUIState((s) => s.focusedEntity);
+
+ return (
+ {
+ 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/FootnoteTextToolbar.tsx b/components/Toolbar/FootnoteTextToolbar.tsx
new file mode 100644
index 00000000..09d7cad5
--- /dev/null
+++ b/components/Toolbar/FootnoteTextToolbar.tsx
@@ -0,0 +1,70 @@
+import { Separator, ShortcutKey } from "components/Layout";
+import { metaKey } from "src/utils/metaKey";
+import { LinkButton } from "./InlineLinkToolbar";
+import { TextDecorationButton } from "./TextDecorationButton";
+import { schema } from "components/Blocks/TextBlock/schema";
+import { BoldSmall, ItalicSmall, StrikethroughSmall } from "./TextToolbar";
+import { isMac } from "src/utils/isDevice";
+export const FootnoteTextToolbar = (props: {
+ setToolbarState: (s: "default" | "link") => void;
+}) => {
+ return (
+ <>
+
+ Bold
+
+ {metaKey()} +{" "}
+ B
+
+
+ }
+ mark={schema.marks.strong}
+ icon={}
+ />
+
+
+ Italic
+
+ {metaKey()} +{" "}
+ I
+
+
+ }
+ mark={schema.marks.em}
+ icon={}
+ />
+
+
+ Strikethrough
+
+
+ {isMac() ? (
+ <>
+ ⌘ +{" "}
+ Ctrl +{" "}
+ X
+ >
+ ) : (
+ <>
+ Ctrl +{" "}
+ Meta +{" "}
+ X
+ >
+ )}
+
+
+ }
+ mark={schema.marks.strikethrough}
+ icon={}
+ />
+
+
+ >
+ );
+};
diff --git a/components/Toolbar/FootnoteToolbarWrapper.tsx b/components/Toolbar/FootnoteToolbarWrapper.tsx
new file mode 100644
index 00000000..22ec52a3
--- /dev/null
+++ b/components/Toolbar/FootnoteToolbarWrapper.tsx
@@ -0,0 +1,77 @@
+"use client";
+
+import React, { useEffect, useState } from "react";
+import { InlineLinkToolbar } from "./InlineLinkToolbar";
+import { useEditorStates } from "src/state/useEditorState";
+import { useUIState } from "src/useUIState";
+import * as Tooltip from "@radix-ui/react-tooltip";
+import { addShortcut } from "src/shortcuts";
+import { FootnoteTextToolbar } from "./FootnoteTextToolbar";
+import { useIsMobile } from "src/hooks/isMobile";
+import { CloseTiny } from "components/Icons/CloseTiny";
+
+type FootnoteToolbarState = "default" | "link";
+
+export const FootnoteToolbar = (props: { pageID: string }) => {
+ let [toolbarState, setToolbarState] = useState("default");
+ let focusedEntity = useUIState((s) => s.focusedEntity);
+ let activeEditor = useEditorStates((s) =>
+ focusedEntity ? s.editorStates[focusedEntity.entityID] : null,
+ );
+
+ useEffect(() => {
+ if (toolbarState !== "default") return;
+ let removeShortcut = addShortcut({
+ metaKey: true,
+ key: "k",
+ handler: () => {
+ setToolbarState("link");
+ },
+ });
+ return () => {
+ removeShortcut();
+ };
+ }, [toolbarState]);
+
+ let isMobile = useIsMobile();
+ return (
+
+
+
+ {toolbarState === "default" ? (
+
+ ) : toolbarState === "link" ? (
+ {
+ activeEditor?.view?.focus();
+ setToolbarState("default");
+ }}
+ />
+ ) : null}
+
+
+
+
+ );
+};
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 aa81d907..1a8a7de0 100644
--- a/lexicons/api/lexicons.ts
+++ b/lexicons/api/lexicons.ts
@@ -2035,6 +2035,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',
],
},
},
@@ -2136,6 +2137,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 59b40726..d857270e 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/useUIState.ts b/src/useUIState.ts
index a205c794..713fe980 100644
--- a/src/useUIState.ts
+++ b/src/useUIState.ts
@@ -10,6 +10,7 @@ export const useUIState = create(
focusedEntity: null as
| { entityType: "page"; entityID: string }
| { entityType: "block"; entityID: string; parent: string }
+ | { entityType: "footnote"; entityID: string; parent: string }
| null,
foldedBlocks: [] as string[],
openPages: [] as string[],
@@ -47,6 +48,7 @@ export const useUIState = create(
b:
| { entityType: "page"; entityID: string }
| { entityType: "block"; entityID: string; parent: string }
+ | { entityType: "footnote"; entityID: string; parent: string }
| null,
) => set(() => ({ focusedEntity: b })),
setSelectedBlock: (block: SelectedBlock) =>
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))
--
2.54.0