Skip to content

Commit 9581433

Browse files
committed
Fixes #212: adds footnote support
Based on https://www.markdownlang.com/extended/footnotes.html * Both "official" [^1] and [^1]: def * And inline ^[My foot note] Features: * Properly parsed in markdown parser * Syntax highlighting * Live Preview to ... when on-hover preview of content, on-click moves cursor to definition * Reference completion * Invalid references are marked
1 parent 4bb258c commit 9581433

File tree

11 files changed

+859
-5
lines changed

11 files changed

+859
-5
lines changed

client/codemirror/clean.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { hashtagPlugin } from "./hashtag.ts";
1717
import type { ClickEvent } from "@silverbulletmd/silverbullet/type/client";
1818
import { attributePlugin } from "./attribute.ts";
1919
import { customSyntaxPlugin } from "./custom_syntax_widget.ts";
20+
import { footnotePlugin } from "./footnote.ts";
2021

2122
export function cleanModePlugins(client: Client) {
2223
const pluginsNeededEvenWhenRenderingSyntax = [
@@ -58,5 +59,6 @@ export function cleanModePlugins(client: Client) {
5859
listBulletPlugin(),
5960
tablePlugin(client),
6061
cleanEscapePlugin(),
62+
...footnotePlugin(() => client.editorView),
6163
] as Extension[];
6264
}

client/codemirror/footnote.ts

Lines changed: 324 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,324 @@
1+
import { syntaxTree } from "@codemirror/language";
2+
import { Decoration, EditorView, WidgetType } from "@codemirror/view";
3+
import { hoverTooltip } from "@codemirror/view";
4+
import type { EditorState, Extension } from "@codemirror/state";
5+
import { decoratorStateField, isCursorInRange } from "./util.ts";
6+
import { parseMarkdown } from "../markdown_parser/parser.ts";
7+
import { renderMarkdownToHtml } from "../markdown_renderer/markdown_render.ts";
8+
9+
function outdentFootnoteBody(text: string): string {
10+
return text.replace(/^(?: |\t)/gm, "");
11+
}
12+
13+
function renderMarkdownTooltip(markdownText: string): HTMLElement {
14+
const dom = document.createElement("div");
15+
dom.className = "sb-footnote-tooltip";
16+
const tree = parseMarkdown(outdentFootnoteBody(markdownText.trim()));
17+
dom.innerHTML = renderMarkdownToHtml(tree);
18+
return dom;
19+
}
20+
21+
class InlineFootnoteWidget extends WidgetType {
22+
constructor(readonly content: string) {
23+
super();
24+
}
25+
26+
toDOM(): HTMLElement {
27+
const span = document.createElement("span");
28+
span.className = "sb-footnote-ref";
29+
span.textContent = "…";
30+
return span;
31+
}
32+
33+
override eq(other: WidgetType): boolean {
34+
return (
35+
other instanceof InlineFootnoteWidget && this.content === other.content
36+
);
37+
}
38+
}
39+
40+
class FootnoteRefWidget extends WidgetType {
41+
constructor(
42+
readonly label: string,
43+
readonly resolved: boolean,
44+
readonly callback: (e: MouseEvent) => void,
45+
) {
46+
super();
47+
}
48+
49+
toDOM(): HTMLElement {
50+
const span = document.createElement("span");
51+
span.className = this.resolved
52+
? "sb-footnote-ref"
53+
: "sb-footnote-ref sb-footnote-ref-unresolved";
54+
span.textContent = "…";
55+
// Use mousedown to intercept before CodeMirror moves the cursor
56+
// (which would remove the widget via isCursorInRange)
57+
span.addEventListener("mousedown", (e) => {
58+
e.preventDefault();
59+
e.stopPropagation();
60+
this.callback(e);
61+
});
62+
return span;
63+
}
64+
65+
override eq(other: WidgetType): boolean {
66+
return (
67+
other instanceof FootnoteRefWidget &&
68+
this.label === other.label &&
69+
this.resolved === other.resolved
70+
);
71+
}
72+
}
73+
74+
type FootnoteDefInfo = { bodyText: string; from: number } | null;
75+
76+
function findFootnoteDef(
77+
state: EditorState,
78+
targetLabel: string,
79+
): FootnoteDefInfo {
80+
let result: FootnoteDefInfo = null;
81+
syntaxTree(state).iterate({
82+
enter: ({ type, from, to, node }) => {
83+
if (type.name === "FootnoteDefinition" && !result) {
84+
const cursor = node.cursor();
85+
cursor.firstChild();
86+
do {
87+
if (cursor.name === "FootnoteDefLabel") {
88+
const labelText = state.sliceDoc(cursor.from, cursor.to);
89+
if (labelText === targetLabel) {
90+
const bodyCursor = node.cursor();
91+
bodyCursor.firstChild();
92+
do {
93+
if (bodyCursor.name === "FootnoteDefBody") {
94+
result = {
95+
bodyText: state.sliceDoc(bodyCursor.from, bodyCursor.to),
96+
from: from,
97+
};
98+
break;
99+
}
100+
} while (bodyCursor.nextSibling());
101+
}
102+
break;
103+
}
104+
} while (cursor.nextSibling());
105+
}
106+
},
107+
});
108+
return result;
109+
}
110+
111+
function footnoteRefDecorator(editorView: () => EditorView) {
112+
return decoratorStateField((state) => {
113+
const widgets: any[] = [];
114+
115+
syntaxTree(state).iterate({
116+
enter: ({ type, from, to, node }) => {
117+
if (type.name !== "FootnoteRef") {
118+
return;
119+
}
120+
121+
if (isCursorInRange(state, [from, to])) {
122+
return;
123+
}
124+
125+
// Extract label from the FootnoteRefLabel child
126+
const cursor = node.cursor();
127+
let labelText = "";
128+
cursor.firstChild();
129+
do {
130+
if (cursor.name === "FootnoteRefLabel") {
131+
labelText = state.sliceDoc(cursor.from, cursor.to);
132+
break;
133+
}
134+
} while (cursor.nextSibling());
135+
136+
if (labelText) {
137+
const resolved = findFootnoteDef(state, labelText) !== null;
138+
const refFrom = from;
139+
widgets.push(
140+
Decoration.replace({
141+
widget: new FootnoteRefWidget(labelText, resolved, (e) => {
142+
const view = editorView();
143+
if (e.altKey || !resolved) {
144+
// Alt-click or unresolved: move cursor into the ref marker
145+
view.dispatch({
146+
selection: { anchor: refFrom + 2 }, // after [^
147+
});
148+
view.focus();
149+
} else {
150+
// Normal click: jump to definition
151+
const def = findFootnoteDef(state, labelText);
152+
if (def) {
153+
view.dispatch({
154+
selection: { anchor: def.from },
155+
scrollIntoView: true,
156+
});
157+
view.focus();
158+
}
159+
}
160+
}),
161+
}).range(from, to),
162+
);
163+
}
164+
},
165+
});
166+
167+
return Decoration.set(widgets, true);
168+
});
169+
}
170+
171+
const inlineFootnoteDecorator = decoratorStateField((state) => {
172+
const widgets: any[] = [];
173+
174+
syntaxTree(state).iterate({
175+
enter: ({ type, from, to, node }) => {
176+
if (type.name !== "InlineFootnote") {
177+
return;
178+
}
179+
180+
if (isCursorInRange(state, [from, to])) {
181+
return;
182+
}
183+
184+
// Extract content from the InlineFootnoteContent child
185+
const cursor = node.cursor();
186+
let content = "";
187+
cursor.firstChild();
188+
do {
189+
if (cursor.name === "InlineFootnoteContent") {
190+
content = state.sliceDoc(cursor.from, cursor.to);
191+
break;
192+
}
193+
} while (cursor.nextSibling());
194+
195+
if (content) {
196+
widgets.push(
197+
Decoration.replace({
198+
widget: new InlineFootnoteWidget(content),
199+
}).range(from, to),
200+
);
201+
}
202+
},
203+
});
204+
205+
return Decoration.set(widgets, true);
206+
});
207+
208+
const footnoteDefDecorator = decoratorStateField((state) => {
209+
const widgets: any[] = [];
210+
211+
syntaxTree(state).iterate({
212+
enter: ({ type, from, to }) => {
213+
if (type.name !== "FootnoteDefinition") {
214+
return;
215+
}
216+
217+
const firstLine = state.doc.lineAt(from);
218+
const lastLine = state.doc.lineAt(to);
219+
for (let l = firstLine.number; l <= lastLine.number; l++) {
220+
widgets.push(
221+
Decoration.line({
222+
class: "sb-footnote-def-line",
223+
}).range(state.doc.line(l).from),
224+
);
225+
}
226+
},
227+
});
228+
229+
return Decoration.set(widgets, true);
230+
});
231+
232+
const footnoteTooltip = hoverTooltip((view, pos) => {
233+
const tree = syntaxTree(view.state);
234+
const node = tree.resolveInner(pos, 1);
235+
236+
// Check if we're hovering over a FootnoteRef or its children
237+
let refNode = node;
238+
while (refNode && refNode.name !== "FootnoteRef") {
239+
refNode = refNode.parent!;
240+
}
241+
if (!refNode || refNode.name !== "FootnoteRef") {
242+
return null;
243+
}
244+
245+
// Extract label
246+
const cursor = refNode.cursor();
247+
let labelText = "";
248+
cursor.firstChild();
249+
do {
250+
if (cursor.name === "FootnoteRefLabel") {
251+
labelText = view.state.sliceDoc(cursor.from, cursor.to);
252+
break;
253+
}
254+
} while (cursor.nextSibling());
255+
256+
if (!labelText) {
257+
return null;
258+
}
259+
260+
const def = findFootnoteDef(view.state, labelText);
261+
262+
return {
263+
pos: refNode.from,
264+
end: refNode.to,
265+
above: true,
266+
create() {
267+
if (def) {
268+
return { dom: renderMarkdownTooltip(def.bodyText) };
269+
}
270+
const dom = document.createElement("div");
271+
dom.className = "sb-footnote-tooltip sb-footnote-tooltip-error";
272+
dom.textContent = `Footnote [^${labelText}] is not defined`;
273+
return { dom };
274+
},
275+
};
276+
});
277+
278+
const inlineFootnoteTooltip = hoverTooltip((view, pos) => {
279+
const tree = syntaxTree(view.state);
280+
const node = tree.resolveInner(pos, 1);
281+
282+
// Check if we're hovering over an InlineFootnote or its children
283+
let fnNode = node;
284+
while (fnNode && fnNode.name !== "InlineFootnote") {
285+
fnNode = fnNode.parent!;
286+
}
287+
if (!fnNode || fnNode.name !== "InlineFootnote") {
288+
return null;
289+
}
290+
291+
// Extract content
292+
const cursor = fnNode.cursor();
293+
let content = "";
294+
cursor.firstChild();
295+
do {
296+
if (cursor.name === "InlineFootnoteContent") {
297+
content = view.state.sliceDoc(cursor.from, cursor.to);
298+
break;
299+
}
300+
} while (cursor.nextSibling());
301+
302+
if (!content) {
303+
return null;
304+
}
305+
306+
return {
307+
pos: fnNode.from,
308+
end: fnNode.to,
309+
above: true,
310+
create() {
311+
return { dom: renderMarkdownTooltip(content) };
312+
},
313+
};
314+
});
315+
316+
export function footnotePlugin(editorView: () => EditorView): Extension[] {
317+
return [
318+
footnoteRefDecorator(editorView),
319+
inlineFootnoteDecorator,
320+
footnoteDefDecorator,
321+
footnoteTooltip,
322+
inlineFootnoteTooltip,
323+
];
324+
}

client/markdown_parser/customtags.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,7 @@ export const DirectiveTag = Tag.define();
2929

3030
export const SubscriptTag = Tag.define();
3131
export const SuperscriptTag = Tag.define();
32+
33+
export const FootnoteRefTag = Tag.define();
34+
export const FootnoteDefTag = Tag.define();
35+
export const InlineFootnoteTag = Tag.define();

0 commit comments

Comments
 (0)