import { syntaxTree } from "@codemirror/language"; import { Decoration, type EditorView, WidgetType } from "@codemirror/view"; import { hoverTooltip } from "@codemirror/view"; import type { EditorState, Extension } from "@codemirror/state"; import { decoratorStateField, isCursorInRange } from "./util.ts"; import { parseMarkdown } from "../markdown_parser/parser.ts"; import { renderMarkdownToHtml } from "../markdown_renderer/markdown_render.ts"; function outdentFootnoteBody(text: string): string { return text.replace(/^(?: {4}|\t)/gm, ""); } function renderMarkdownTooltip(markdownText: string): HTMLElement { const dom = document.createElement("div"); dom.className = "sb-footnote-tooltip"; const tree = parseMarkdown(outdentFootnoteBody(markdownText.trim())); dom.innerHTML = renderMarkdownToHtml(tree); return dom; } class InlineFootnoteWidget extends WidgetType { constructor(readonly content: string) { super(); } toDOM(): HTMLElement { const span = document.createElement("span"); span.className = "sb-footnote-ref"; span.textContent = "…"; return span; } override eq(other: WidgetType): boolean { return ( other instanceof InlineFootnoteWidget && this.content === other.content ); } } class FootnoteRefWidget extends WidgetType { constructor( readonly label: string, readonly resolved: boolean, readonly callback: (e: MouseEvent) => void, ) { super(); } toDOM(): HTMLElement { const span = document.createElement("span"); span.className = this.resolved ? "sb-footnote-ref" : "sb-footnote-ref sb-footnote-ref-unresolved"; span.textContent = "…"; // Use mousedown to intercept before CodeMirror moves the cursor // (which would remove the widget via isCursorInRange) span.addEventListener("mousedown", (e) => { e.preventDefault(); e.stopPropagation(); this.callback(e); }); return span; } override eq(other: WidgetType): boolean { return ( other instanceof FootnoteRefWidget && this.label === other.label && this.resolved === other.resolved ); } } type FootnoteDefInfo = { bodyText: string; from: number } | null; function findFootnoteDef( state: EditorState, targetLabel: string, ): FootnoteDefInfo { let result: FootnoteDefInfo = null; syntaxTree(state).iterate({ enter: ({ type, from, node }) => { if (type.name === "FootnoteDefinition" && !result) { const cursor = node.cursor(); cursor.firstChild(); do { if (cursor.name === "FootnoteDefLabel") { const labelText = state.sliceDoc(cursor.from, cursor.to); if (labelText === targetLabel) { const bodyCursor = node.cursor(); bodyCursor.firstChild(); do { if (bodyCursor.name === "FootnoteDefBody") { result = { bodyText: state.sliceDoc(bodyCursor.from, bodyCursor.to), from: from, }; break; } } while (bodyCursor.nextSibling()); } break; } } while (cursor.nextSibling()); } }, }); return result; } function footnoteRefDecorator(editorView: () => EditorView) { return decoratorStateField((state) => { const widgets: any[] = []; syntaxTree(state).iterate({ enter: ({ type, from, to, node }) => { if (type.name !== "FootnoteRef") { return; } if (isCursorInRange(state, [from, to])) { return; } // Extract label from the FootnoteRefLabel child const cursor = node.cursor(); let labelText = ""; cursor.firstChild(); do { if (cursor.name === "FootnoteRefLabel") { labelText = state.sliceDoc(cursor.from, cursor.to); break; } } while (cursor.nextSibling()); if (labelText) { const resolved = findFootnoteDef(state, labelText) !== null; const refFrom = from; widgets.push( Decoration.replace({ widget: new FootnoteRefWidget(labelText, resolved, (e) => { const view = editorView(); if (e.altKey || !resolved) { // Alt-click or unresolved: move cursor into the ref marker view.dispatch({ selection: { anchor: refFrom + 2 }, // after [^ }); view.focus(); } else { // Normal click: jump to definition const def = findFootnoteDef(state, labelText); if (def) { view.dispatch({ selection: { anchor: def.from }, scrollIntoView: true, }); view.focus(); } } }), }).range(from, to), ); } }, }); return Decoration.set(widgets, true); }); } const inlineFootnoteDecorator = decoratorStateField((state) => { const widgets: any[] = []; syntaxTree(state).iterate({ enter: ({ type, from, to, node }) => { if (type.name !== "InlineFootnote") { return; } if (isCursorInRange(state, [from, to])) { return; } // Extract content from the InlineFootnoteContent child const cursor = node.cursor(); let content = ""; cursor.firstChild(); do { if (cursor.name === "InlineFootnoteContent") { content = state.sliceDoc(cursor.from, cursor.to); break; } } while (cursor.nextSibling()); if (content) { widgets.push( Decoration.replace({ widget: new InlineFootnoteWidget(content), }).range(from, to), ); } }, }); return Decoration.set(widgets, true); }); const footnoteDefDecorator = decoratorStateField((state) => { const widgets: any[] = []; syntaxTree(state).iterate({ enter: ({ type, from, to }) => { if (type.name !== "FootnoteDefinition") { return; } const firstLine = state.doc.lineAt(from); const lastLine = state.doc.lineAt(to); for (let l = firstLine.number; l <= lastLine.number; l++) { widgets.push( Decoration.line({ class: "sb-footnote-def-line", }).range(state.doc.line(l).from), ); } }, }); return Decoration.set(widgets, true); }); const footnoteTooltip = hoverTooltip((view, pos) => { const tree = syntaxTree(view.state); const node = tree.resolveInner(pos, 1); // Check if we're hovering over a FootnoteRef or its children let refNode = node; while (refNode && refNode.name !== "FootnoteRef") { refNode = refNode.parent!; } if (!refNode || refNode.name !== "FootnoteRef") { return null; } // Extract label const cursor = refNode.cursor(); let labelText = ""; cursor.firstChild(); do { if (cursor.name === "FootnoteRefLabel") { labelText = view.state.sliceDoc(cursor.from, cursor.to); break; } } while (cursor.nextSibling()); if (!labelText) { return null; } const def = findFootnoteDef(view.state, labelText); return { pos: refNode.from, end: refNode.to, above: true, create() { if (def) { return { dom: renderMarkdownTooltip(def.bodyText) }; } const dom = document.createElement("div"); dom.className = "sb-footnote-tooltip sb-footnote-tooltip-error"; dom.textContent = `Footnote [^${labelText}] is not defined`; return { dom }; }, }; }); const inlineFootnoteTooltip = hoverTooltip((view, pos) => { const tree = syntaxTree(view.state); const node = tree.resolveInner(pos, 1); // Check if we're hovering over an InlineFootnote or its children let fnNode = node; while (fnNode && fnNode.name !== "InlineFootnote") { fnNode = fnNode.parent!; } if (!fnNode || fnNode.name !== "InlineFootnote") { return null; } // Extract content const cursor = fnNode.cursor(); let content = ""; cursor.firstChild(); do { if (cursor.name === "InlineFootnoteContent") { content = view.state.sliceDoc(cursor.from, cursor.to); break; } } while (cursor.nextSibling()); if (!content) { return null; } return { pos: fnNode.from, end: fnNode.to, above: true, create() { return { dom: renderMarkdownTooltip(content) }; }, }; }); export function footnotePlugin(editorView: () => EditorView): Extension[] { return [ footnoteRefDecorator(editorView), inlineFootnoteDecorator, footnoteDefDecorator, footnoteTooltip, inlineFootnoteTooltip, ]; }