🚧 Added support for markdown preview footnotes
This commit is contained in:
@@ -10,6 +10,7 @@ import {StandardSQL} from "@codemirror/lang-sql";
|
|||||||
import {markdown, markdownLanguage} from "@codemirror/lang-markdown";
|
import {markdown, markdownLanguage} from "@codemirror/lang-markdown";
|
||||||
import {Subscript, Superscript, Table} from "@lezer/markdown";
|
import {Subscript, Superscript, Table} from "@lezer/markdown";
|
||||||
import {Highlight} from "@/views/editor/extensions/markdown/syntax/highlight";
|
import {Highlight} from "@/views/editor/extensions/markdown/syntax/highlight";
|
||||||
|
import {Footnote} from "@/views/editor/extensions/markdown/syntax/footnote";
|
||||||
import {javaLanguage} from "@codemirror/lang-java";
|
import {javaLanguage} from "@codemirror/lang-java";
|
||||||
import {phpLanguage} from "@codemirror/lang-php";
|
import {phpLanguage} from "@codemirror/lang-php";
|
||||||
import {cssLanguage} from "@codemirror/lang-css";
|
import {cssLanguage} from "@codemirror/lang-css";
|
||||||
@@ -115,7 +116,7 @@ export const LANGUAGES: LanguageInfo[] = [
|
|||||||
}),
|
}),
|
||||||
new LanguageInfo("md", "Markdown", markdown({
|
new LanguageInfo("md", "Markdown", markdown({
|
||||||
base: markdownLanguage,
|
base: markdownLanguage,
|
||||||
extensions: [Subscript, Superscript, Highlight, Table],
|
extensions: [Subscript, Superscript, Highlight, Footnote, Table],
|
||||||
completeHTMLTags: true,
|
completeHTMLTags: true,
|
||||||
pasteURLAsLink: true,
|
pasteURLAsLink: true,
|
||||||
htmlTagLanguage: html({
|
htmlTagLanguage: html({
|
||||||
|
|||||||
@@ -1,94 +0,0 @@
|
|||||||
/**
|
|
||||||
* A single source of truth for all the classes used for decorations in Ixora.
|
|
||||||
* These are kept together here to simplify changing/adding classes later
|
|
||||||
* and serve as a reference.
|
|
||||||
*
|
|
||||||
* Exports under this file don't need to follow any particular naming schema,
|
|
||||||
* naming which can give an intuition on what the class is for is preferred.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** Classes for blockquote decorations. */
|
|
||||||
export const blockquote = {
|
|
||||||
/** Blockquote widget */
|
|
||||||
widget: 'cm-blockquote',
|
|
||||||
/** Replace decoration for the quote mark */
|
|
||||||
mark: 'cm-blockquote-border'
|
|
||||||
},
|
|
||||||
/** Classes for codeblock decorations. */
|
|
||||||
codeblock = {
|
|
||||||
/** Codeblock widget */
|
|
||||||
widget: 'cm-codeblock',
|
|
||||||
/** First line of the codeblock widget */
|
|
||||||
widgetBegin: 'cm-codeblock-begin',
|
|
||||||
/** Last line of the codeblock widget */
|
|
||||||
widgetEnd: 'cm-codeblock-end'
|
|
||||||
},
|
|
||||||
/** Classes for heading decorations. */
|
|
||||||
heading = {
|
|
||||||
/** Heading decoration class */
|
|
||||||
heading: 'cm-heading',
|
|
||||||
/** Heading levels (h1, h2, etc) */
|
|
||||||
level: (level: number) => `cm-heading-${level}`,
|
|
||||||
/** Heading slug */
|
|
||||||
slug: (slug: string) => `cm-heading-slug-${slug}`
|
|
||||||
},
|
|
||||||
/** Classes for link (URL) widgets. */
|
|
||||||
link = {
|
|
||||||
/** URL widget */
|
|
||||||
widget: 'cm-link'
|
|
||||||
},
|
|
||||||
/** Classes for list widgets. */
|
|
||||||
list = {
|
|
||||||
/** List bullet */
|
|
||||||
bullet: 'cm-list-bullet',
|
|
||||||
/** List task checkbox */
|
|
||||||
taskCheckbox: 'cm-task-marker-checkbox',
|
|
||||||
/** Task list item with checkbox checked */
|
|
||||||
taskChecked: 'cm-task-checked'
|
|
||||||
},
|
|
||||||
/** Classes for image widgets. */
|
|
||||||
image = {
|
|
||||||
/** Image preview */
|
|
||||||
widget: 'cm-image'
|
|
||||||
},
|
|
||||||
/** Classes for enhanced code block decorations. */
|
|
||||||
codeblockEnhanced = {
|
|
||||||
/** Code block info container */
|
|
||||||
info: 'cm-code-block-info',
|
|
||||||
/** Language label */
|
|
||||||
lang: 'cm-code-block-lang',
|
|
||||||
/** Copy button */
|
|
||||||
copyBtn: 'cm-code-block-copy-btn'
|
|
||||||
},
|
|
||||||
/** Classes for table decorations. */
|
|
||||||
table = {
|
|
||||||
/** Table container wrapper */
|
|
||||||
wrapper: 'cm-table-wrapper',
|
|
||||||
/** The rendered table element */
|
|
||||||
table: 'cm-table',
|
|
||||||
/** Table header row */
|
|
||||||
header: 'cm-table-header',
|
|
||||||
/** Table header cell */
|
|
||||||
headerCell: 'cm-table-header-cell',
|
|
||||||
/** Table body */
|
|
||||||
body: 'cm-table-body',
|
|
||||||
/** Table data row */
|
|
||||||
row: 'cm-table-row',
|
|
||||||
/** Table data cell */
|
|
||||||
cell: 'cm-table-cell',
|
|
||||||
/** Cell alignment classes */
|
|
||||||
alignLeft: 'cm-table-align-left',
|
|
||||||
alignCenter: 'cm-table-align-center',
|
|
||||||
alignRight: 'cm-table-align-right',
|
|
||||||
/** Cell content wrapper (for editing) */
|
|
||||||
cellContent: 'cm-table-cell-content',
|
|
||||||
/** Resize handle */
|
|
||||||
resizeHandle: 'cm-table-resize-handle',
|
|
||||||
/** Active editing cell */
|
|
||||||
cellActive: 'cm-table-cell-active',
|
|
||||||
/** Row hover state */
|
|
||||||
rowHover: 'cm-table-row-hover',
|
|
||||||
/** Selected cell */
|
|
||||||
cellSelected: 'cm-table-cell-selected'
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -3,7 +3,6 @@ import { blockquote } from './plugins/blockquote';
|
|||||||
import { codeblock } from './plugins/code-block';
|
import { codeblock } from './plugins/code-block';
|
||||||
import { headings } from './plugins/heading';
|
import { headings } from './plugins/heading';
|
||||||
import { hideMarks } from './plugins/hide-mark';
|
import { hideMarks } from './plugins/hide-mark';
|
||||||
import { htmlBlockExtension } from './plugins/html';
|
|
||||||
import { image } from './plugins/image';
|
import { image } from './plugins/image';
|
||||||
import { links } from './plugins/link';
|
import { links } from './plugins/link';
|
||||||
import { lists } from './plugins/list';
|
import { lists } from './plugins/list';
|
||||||
@@ -13,8 +12,7 @@ import { horizontalRule } from './plugins/horizontal-rule';
|
|||||||
import { inlineCode } from './plugins/inline-code';
|
import { inlineCode } from './plugins/inline-code';
|
||||||
import { subscriptSuperscript } from './plugins/subscript-superscript';
|
import { subscriptSuperscript } from './plugins/subscript-superscript';
|
||||||
import { highlight } from './plugins/highlight';
|
import { highlight } from './plugins/highlight';
|
||||||
import { table } from './plugins/table';
|
import { footnote } from './plugins/footnote';
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* markdown extensions
|
* markdown extensions
|
||||||
@@ -28,13 +26,12 @@ export const markdownExtensions: Extension = [
|
|||||||
lists(),
|
lists(),
|
||||||
links(),
|
links(),
|
||||||
image(),
|
image(),
|
||||||
htmlBlockExtension,
|
|
||||||
emoji(),
|
emoji(),
|
||||||
horizontalRule(),
|
horizontalRule(),
|
||||||
inlineCode(),
|
inlineCode(),
|
||||||
subscriptSuperscript(),
|
subscriptSuperscript(),
|
||||||
highlight(),
|
highlight(),
|
||||||
table(),
|
footnote(),
|
||||||
];
|
];
|
||||||
|
|
||||||
export default markdownExtensions;
|
export default markdownExtensions;
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
import { Range } from '@codemirror/state';
|
import { Range } from '@codemirror/state';
|
||||||
import { syntaxTree } from '@codemirror/language';
|
import { syntaxTree } from '@codemirror/language';
|
||||||
import { isCursorInRange, invisibleDecoration } from '../util';
|
import { isCursorInRange, invisibleDecoration } from '../util';
|
||||||
import { blockquote as classes } from '../classes';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Blockquote plugin.
|
* Blockquote plugin.
|
||||||
@@ -47,7 +46,7 @@ function buildBlockQuoteDecorations(view: EditorView): DecorationSet {
|
|||||||
processedLines.add(i);
|
processedLines.add(i);
|
||||||
const line = view.state.doc.line(i);
|
const line = view.state.doc.line(i);
|
||||||
decorations.push(
|
decorations.push(
|
||||||
Decoration.line({ class: classes.widget }).range(line.from)
|
Decoration.line({ class: 'cm-blockquote' }).range(line.from)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -96,7 +95,7 @@ const blockQuotePlugin = ViewPlugin.fromClass(BlockQuotePlugin, {
|
|||||||
* Base theme for blockquotes.
|
* Base theme for blockquotes.
|
||||||
*/
|
*/
|
||||||
const baseTheme = EditorView.baseTheme({
|
const baseTheme = EditorView.baseTheme({
|
||||||
[`.${classes.widget}`]: {
|
'.cm-blockquote': {
|
||||||
borderLeft: '4px solid var(--cm-blockquote-border, #ccc)',
|
borderLeft: '4px solid var(--cm-blockquote-border, #ccc)',
|
||||||
color: 'var(--cm-blockquote-color, #666)'
|
color: 'var(--cm-blockquote-color, #666)'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
} from '@codemirror/view';
|
} from '@codemirror/view';
|
||||||
import { syntaxTree } from '@codemirror/language';
|
import { syntaxTree } from '@codemirror/language';
|
||||||
import { isCursorInRange } from '../util';
|
import { isCursorInRange } from '../util';
|
||||||
import { codeblock as classes, codeblockEnhanced as enhancedClasses } from '../classes';
|
|
||||||
|
|
||||||
/** Code block node types in syntax tree */
|
/** Code block node types in syntax tree */
|
||||||
const CODE_BLOCK_TYPES = ['FencedCode', 'CodeBlock'] as const;
|
const CODE_BLOCK_TYPES = ['FencedCode', 'CodeBlock'] as const;
|
||||||
@@ -22,7 +21,7 @@ const ICON_CHECK = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" s
|
|||||||
interface CodeBlockData {
|
interface CodeBlockData {
|
||||||
from: number;
|
from: number;
|
||||||
to: number;
|
to: number;
|
||||||
language: string;
|
language: string | null;
|
||||||
content: string;
|
content: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,10 +38,13 @@ export const codeblock = (): Extension => [codeBlockPlugin, baseTheme];
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Widget for displaying language label and copy button.
|
* Widget for displaying language label and copy button.
|
||||||
* Uses ignoreEvent: true to prevent editor focus changes.
|
* Handles click events directly on the button element.
|
||||||
*/
|
*/
|
||||||
class CodeBlockInfoWidget extends WidgetType {
|
class CodeBlockInfoWidget extends WidgetType {
|
||||||
constructor(readonly data: CodeBlockData) {
|
constructor(
|
||||||
|
readonly data: CodeBlockData,
|
||||||
|
readonly view: EditorView
|
||||||
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,26 +55,51 @@ class CodeBlockInfoWidget extends WidgetType {
|
|||||||
|
|
||||||
toDOM(): HTMLElement {
|
toDOM(): HTMLElement {
|
||||||
const container = document.createElement('span');
|
const container = document.createElement('span');
|
||||||
container.className = enhancedClasses.info;
|
container.className = 'cm-code-block-info';
|
||||||
container.dataset.codeFrom = String(this.data.from);
|
|
||||||
|
|
||||||
// Language label
|
// Only show language label if specified
|
||||||
|
if (this.data.language) {
|
||||||
const lang = document.createElement('span');
|
const lang = document.createElement('span');
|
||||||
lang.className = enhancedClasses.lang;
|
lang.className = 'cm-code-block-lang';
|
||||||
lang.textContent = this.data.language;
|
lang.textContent = this.data.language;
|
||||||
|
container.append(lang);
|
||||||
|
}
|
||||||
|
|
||||||
// Copy button
|
|
||||||
const btn = document.createElement('button');
|
const btn = document.createElement('button');
|
||||||
btn.className = enhancedClasses.copyBtn;
|
btn.className = 'cm-code-block-copy-btn';
|
||||||
btn.title = 'Copy';
|
btn.title = 'Copy';
|
||||||
btn.innerHTML = ICON_COPY;
|
btn.innerHTML = ICON_COPY;
|
||||||
btn.dataset.codeContent = this.data.content;
|
|
||||||
|
|
||||||
container.append(lang, btn);
|
// Direct click handler - more reliable than eventHandlers
|
||||||
|
btn.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
this.handleCopy(btn);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Prevent mousedown from affecting editor
|
||||||
|
btn.addEventListener('mousedown', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
});
|
||||||
|
|
||||||
|
container.append(btn);
|
||||||
return container;
|
return container;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Critical: ignore all events to prevent editor focus
|
private handleCopy(btn: HTMLButtonElement): void {
|
||||||
|
const content = getCodeContent(this.view, this.data.from, this.data.to);
|
||||||
|
if (!content) return;
|
||||||
|
|
||||||
|
navigator.clipboard.writeText(content).then(() => {
|
||||||
|
btn.innerHTML = ICON_CHECK;
|
||||||
|
setTimeout(() => {
|
||||||
|
btn.innerHTML = ICON_COPY;
|
||||||
|
}, 1500);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ignore events to prevent editor focus changes
|
||||||
ignoreEvent(): boolean {
|
ignoreEvent(): boolean {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -127,33 +154,28 @@ function buildDecorations(view: EditorView): { decorations: DecorationSet; block
|
|||||||
const startLine = view.state.doc.lineAt(nodeFrom);
|
const startLine = view.state.doc.lineAt(nodeFrom);
|
||||||
const endLine = view.state.doc.lineAt(nodeTo);
|
const endLine = view.state.doc.lineAt(nodeTo);
|
||||||
|
|
||||||
// Line decorations
|
|
||||||
for (let num = startLine.number; num <= endLine.number; num++) {
|
for (let num = startLine.number; num <= endLine.number; num++) {
|
||||||
const line = view.state.doc.line(num);
|
const line = view.state.doc.line(num);
|
||||||
const pos: string[] = [];
|
const pos: string[] = ['cm-codeblock'];
|
||||||
if (num === startLine.number) pos.push(classes.widgetBegin);
|
if (num === startLine.number) pos.push('cm-codeblock-begin');
|
||||||
if (num === endLine.number) pos.push(classes.widgetEnd);
|
if (num === endLine.number) pos.push('cm-codeblock-end');
|
||||||
|
|
||||||
decorations.push(
|
decorations.push(
|
||||||
Decoration.line({
|
Decoration.line({ class: pos.join(' ') }).range(line.from)
|
||||||
class: `${classes.widget} ${pos.join(' ')}`.trim()
|
|
||||||
}).range(line.from)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Info widget (only if language specified)
|
// Info widget with copy button (always show, language label only if specified)
|
||||||
if (language) {
|
|
||||||
const content = getCodeContent(view, nodeFrom, nodeTo);
|
const content = getCodeContent(view, nodeFrom, nodeTo);
|
||||||
const data: CodeBlockData = { from: nodeFrom, to: nodeTo, language, content };
|
const data: CodeBlockData = { from: nodeFrom, to: nodeTo, language, content };
|
||||||
blocks.set(nodeFrom, data);
|
blocks.set(nodeFrom, data);
|
||||||
|
|
||||||
decorations.push(
|
decorations.push(
|
||||||
Decoration.widget({
|
Decoration.widget({
|
||||||
widget: new CodeBlockInfoWidget(data),
|
widget: new CodeBlockInfoWidget(data, view),
|
||||||
side: 1
|
side: 1
|
||||||
}).range(startLine.to)
|
}).range(startLine.to)
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
// Hide markers
|
// Hide markers
|
||||||
node.toTree().iterate({
|
node.toTree().iterate({
|
||||||
@@ -170,21 +192,6 @@ function buildDecorations(view: EditorView): { decorations: DecorationSet; block
|
|||||||
return { decorations: Decoration.set(decorations, true), blocks };
|
return { decorations: Decoration.set(decorations, true), blocks };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle copy button click.
|
|
||||||
*/
|
|
||||||
function handleCopyClick(btn: HTMLButtonElement): void {
|
|
||||||
const content = btn.dataset.codeContent;
|
|
||||||
if (!content) return;
|
|
||||||
|
|
||||||
navigator.clipboard.writeText(content).then(() => {
|
|
||||||
btn.innerHTML = ICON_CHECK;
|
|
||||||
setTimeout(() => {
|
|
||||||
btn.innerHTML = ICON_COPY;
|
|
||||||
}, 1500);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Code block plugin with optimized updates.
|
* Code block plugin with optimized updates.
|
||||||
*/
|
*/
|
||||||
@@ -225,54 +232,28 @@ class CodeBlockPluginClass {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const codeBlockPlugin = ViewPlugin.fromClass(CodeBlockPluginClass, {
|
const codeBlockPlugin = ViewPlugin.fromClass(CodeBlockPluginClass, {
|
||||||
decorations: (v) => v.decorations,
|
decorations: (v) => v.decorations
|
||||||
|
|
||||||
eventHandlers: {
|
|
||||||
// Handle copy button clicks without triggering editor focus
|
|
||||||
mousedown(e: MouseEvent, view: EditorView) {
|
|
||||||
const target = e.target as HTMLElement;
|
|
||||||
|
|
||||||
// Check if clicked on copy button or its SVG child
|
|
||||||
const btn = target.closest(`.${enhancedClasses.copyBtn}`) as HTMLButtonElement;
|
|
||||||
if (btn) {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
handleCopyClick(btn);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if clicked on info container (language label)
|
|
||||||
if (target.closest(`.${enhancedClasses.info}`)) {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Base theme for code blocks.
|
* Base theme for code blocks.
|
||||||
*/
|
*/
|
||||||
const baseTheme = EditorView.baseTheme({
|
const baseTheme = EditorView.baseTheme({
|
||||||
[`.${classes.widget}`]: {
|
'.cm-codeblock': {
|
||||||
backgroundColor: 'var(--cm-codeblock-bg)'
|
backgroundColor: 'var(--cm-codeblock-bg)'
|
||||||
},
|
},
|
||||||
[`.${classes.widgetBegin}`]: {
|
'.cm-codeblock-begin': {
|
||||||
borderTopLeftRadius: 'var(--cm-codeblock-radius)',
|
borderTopLeftRadius: 'var(--cm-codeblock-radius)',
|
||||||
borderTopRightRadius: 'var(--cm-codeblock-radius)',
|
borderTopRightRadius: 'var(--cm-codeblock-radius)',
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
borderTop: '1px solid var(--text-primary)'
|
boxShadow: 'inset 0 1px 0 var(--text-primary)'
|
||||||
},
|
},
|
||||||
[`.${classes.widgetEnd}`]: {
|
'.cm-codeblock-end': {
|
||||||
borderBottomLeftRadius: 'var(--cm-codeblock-radius)',
|
borderBottomLeftRadius: 'var(--cm-codeblock-radius)',
|
||||||
borderBottomRightRadius: 'var(--cm-codeblock-radius)',
|
borderBottomRightRadius: 'var(--cm-codeblock-radius)',
|
||||||
borderBottom: '1px solid var(--text-primary)'
|
boxShadow: 'inset 0 -1px 0 var(--text-primary)'
|
||||||
},
|
},
|
||||||
// Info container
|
'.cm-code-block-info': {
|
||||||
[`.${enhancedClasses.info}`]: {
|
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
right: '8px',
|
right: '8px',
|
||||||
top: '50%',
|
top: '50%',
|
||||||
@@ -284,17 +265,15 @@ const baseTheme = EditorView.baseTheme({
|
|||||||
opacity: '0.5',
|
opacity: '0.5',
|
||||||
transition: 'opacity 0.15s'
|
transition: 'opacity 0.15s'
|
||||||
},
|
},
|
||||||
[`.${enhancedClasses.info}:hover`]: {
|
'.cm-code-block-info:hover': {
|
||||||
opacity: '1'
|
opacity: '1'
|
||||||
},
|
},
|
||||||
// Language label
|
'.cm-code-block-lang': {
|
||||||
[`.${enhancedClasses.lang}`]: {
|
|
||||||
color: 'var(--cm-codeblock-lang, var(--cm-foreground))',
|
color: 'var(--cm-codeblock-lang, var(--cm-foreground))',
|
||||||
textTransform: 'lowercase',
|
textTransform: 'lowercase',
|
||||||
userSelect: 'none'
|
userSelect: 'none'
|
||||||
},
|
},
|
||||||
// Copy button
|
'.cm-code-block-copy-btn': {
|
||||||
[`.${enhancedClasses.copyBtn}`]: {
|
|
||||||
display: 'inline-flex',
|
display: 'inline-flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
@@ -307,11 +286,11 @@ const baseTheme = EditorView.baseTheme({
|
|||||||
opacity: '0.7',
|
opacity: '0.7',
|
||||||
transition: 'opacity 0.15s, background 0.15s'
|
transition: 'opacity 0.15s, background 0.15s'
|
||||||
},
|
},
|
||||||
[`.${enhancedClasses.copyBtn}:hover`]: {
|
'.cm-code-block-copy-btn:hover': {
|
||||||
opacity: '1',
|
opacity: '1',
|
||||||
background: 'rgba(128, 128, 128, 0.2)'
|
background: 'rgba(128, 128, 128, 0.2)'
|
||||||
},
|
},
|
||||||
[`.${enhancedClasses.copyBtn} svg`]: {
|
'.cm-code-block-copy-btn svg': {
|
||||||
width: '1em',
|
width: '1em',
|
||||||
height: '1em'
|
height: '1em'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -157,11 +157,10 @@ const emojiPlugin = ViewPlugin.fromClass(EmojiPlugin, {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Base theme for emoji.
|
* Base theme for emoji.
|
||||||
|
* Inherits font size and line height from parent element.
|
||||||
*/
|
*/
|
||||||
const baseTheme = EditorView.baseTheme({
|
const baseTheme = EditorView.baseTheme({
|
||||||
'.cm-emoji': {
|
'.cm-emoji': {
|
||||||
fontSize: '1.2em',
|
|
||||||
lineHeight: '1',
|
|
||||||
verticalAlign: 'middle',
|
verticalAlign: 'middle',
|
||||||
cursor: 'default'
|
cursor: 'default'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,754 @@
|
|||||||
|
/**
|
||||||
|
* Footnote plugin for CodeMirror.
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Renders footnote references as superscript numbers/labels
|
||||||
|
* - Renders inline footnotes as superscript numbers with embedded content
|
||||||
|
* - Shows footnote content on hover (tooltip)
|
||||||
|
* - Click to jump between reference and definition
|
||||||
|
* - Hides syntax marks when cursor is outside
|
||||||
|
*
|
||||||
|
* Syntax (MultiMarkdown/PHP Markdown Extra):
|
||||||
|
* - Reference: [^id] → renders as superscript
|
||||||
|
* - Definition: [^id]: content
|
||||||
|
* - Inline footnote: ^[content] → renders as superscript with embedded content
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Extension, Range, StateField, EditorState } from '@codemirror/state';
|
||||||
|
import { syntaxTree } from '@codemirror/language';
|
||||||
|
import {
|
||||||
|
ViewPlugin,
|
||||||
|
DecorationSet,
|
||||||
|
Decoration,
|
||||||
|
EditorView,
|
||||||
|
ViewUpdate,
|
||||||
|
WidgetType,
|
||||||
|
hoverTooltip,
|
||||||
|
Tooltip,
|
||||||
|
} from '@codemirror/view';
|
||||||
|
import { isCursorInRange, invisibleDecoration } from '../util';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Information about a footnote definition.
|
||||||
|
*/
|
||||||
|
interface FootnoteDefinition {
|
||||||
|
/** The footnote identifier (e.g., "1", "note") */
|
||||||
|
id: string;
|
||||||
|
/** The content of the footnote */
|
||||||
|
content: string;
|
||||||
|
/** Start position in document */
|
||||||
|
from: number;
|
||||||
|
/** End position in document */
|
||||||
|
to: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Information about a footnote reference.
|
||||||
|
*/
|
||||||
|
interface FootnoteReference {
|
||||||
|
/** The footnote identifier */
|
||||||
|
id: string;
|
||||||
|
/** Start position in document */
|
||||||
|
from: number;
|
||||||
|
/** End position in document */
|
||||||
|
to: number;
|
||||||
|
/** Numeric index (1-based, for display) */
|
||||||
|
index: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Information about an inline footnote.
|
||||||
|
*/
|
||||||
|
interface InlineFootnoteInfo {
|
||||||
|
/** The content of the inline footnote */
|
||||||
|
content: string;
|
||||||
|
/** Start position in document */
|
||||||
|
from: number;
|
||||||
|
/** End position in document */
|
||||||
|
to: number;
|
||||||
|
/** Numeric index (1-based, for display) */
|
||||||
|
index: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collected footnote data from the document.
|
||||||
|
* Uses Maps for O(1) lookup by position and id.
|
||||||
|
*/
|
||||||
|
interface FootnoteData {
|
||||||
|
definitions: Map<string, FootnoteDefinition>;
|
||||||
|
references: FootnoteReference[];
|
||||||
|
inlineFootnotes: InlineFootnoteInfo[];
|
||||||
|
// Index maps for O(1) lookup
|
||||||
|
referencesByPos: Map<number, FootnoteReference>;
|
||||||
|
inlineByPos: Map<number, InlineFootnoteInfo>;
|
||||||
|
firstRefById: Map<string, FootnoteReference>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Footnote Collection
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collect all footnote definitions, references, and inline footnotes from the document.
|
||||||
|
* Builds index maps for O(1) lookup during decoration and tooltip handling.
|
||||||
|
*/
|
||||||
|
function collectFootnotes(state: EditorState): FootnoteData {
|
||||||
|
const definitions = new Map<string, FootnoteDefinition>();
|
||||||
|
const references: FootnoteReference[] = [];
|
||||||
|
const inlineFootnotes: InlineFootnoteInfo[] = [];
|
||||||
|
// Index maps for fast lookup
|
||||||
|
const referencesByPos = new Map<number, FootnoteReference>();
|
||||||
|
const inlineByPos = new Map<number, InlineFootnoteInfo>();
|
||||||
|
const firstRefById = new Map<string, FootnoteReference>();
|
||||||
|
const seenIds = new Map<string, number>();
|
||||||
|
let inlineIndex = 0;
|
||||||
|
|
||||||
|
syntaxTree(state).iterate({
|
||||||
|
enter: ({ type, from, to, node }) => {
|
||||||
|
if (type.name === 'FootnoteDefinition') {
|
||||||
|
const labelNode = node.getChild('FootnoteDefinitionLabel');
|
||||||
|
const contentNode = node.getChild('FootnoteDefinitionContent');
|
||||||
|
|
||||||
|
if (labelNode) {
|
||||||
|
const id = state.sliceDoc(labelNode.from, labelNode.to);
|
||||||
|
const content = contentNode
|
||||||
|
? state.sliceDoc(contentNode.from, contentNode.to).trim()
|
||||||
|
: '';
|
||||||
|
|
||||||
|
definitions.set(id, { id, content, from, to });
|
||||||
|
}
|
||||||
|
} else if (type.name === 'FootnoteReference') {
|
||||||
|
const labelNode = node.getChild('FootnoteReferenceLabel');
|
||||||
|
|
||||||
|
if (labelNode) {
|
||||||
|
const id = state.sliceDoc(labelNode.from, labelNode.to);
|
||||||
|
|
||||||
|
if (!seenIds.has(id)) {
|
||||||
|
seenIds.set(id, seenIds.size + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ref: FootnoteReference = {
|
||||||
|
id,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
index: seenIds.get(id)!,
|
||||||
|
};
|
||||||
|
|
||||||
|
references.push(ref);
|
||||||
|
referencesByPos.set(from, ref);
|
||||||
|
|
||||||
|
// Track first reference for each id
|
||||||
|
if (!firstRefById.has(id)) {
|
||||||
|
firstRefById.set(id, ref);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (type.name === 'InlineFootnote') {
|
||||||
|
const contentNode = node.getChild('InlineFootnoteContent');
|
||||||
|
|
||||||
|
if (contentNode) {
|
||||||
|
const content = state.sliceDoc(contentNode.from, contentNode.to).trim();
|
||||||
|
inlineIndex++;
|
||||||
|
|
||||||
|
const info: InlineFootnoteInfo = {
|
||||||
|
content,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
index: inlineIndex,
|
||||||
|
};
|
||||||
|
|
||||||
|
inlineFootnotes.push(info);
|
||||||
|
inlineByPos.set(from, info);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
definitions,
|
||||||
|
references,
|
||||||
|
inlineFootnotes,
|
||||||
|
referencesByPos,
|
||||||
|
inlineByPos,
|
||||||
|
firstRefById,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// State Field
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* State field to track footnote data across the document.
|
||||||
|
* This allows efficient lookup for tooltips and navigation.
|
||||||
|
*/
|
||||||
|
export const footnoteDataField = StateField.define<FootnoteData>({
|
||||||
|
create(state) {
|
||||||
|
return collectFootnotes(state);
|
||||||
|
},
|
||||||
|
update(value, tr) {
|
||||||
|
if (tr.docChanged) {
|
||||||
|
return collectFootnotes(tr.state);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Widget
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Widget to display footnote reference as superscript.
|
||||||
|
*/
|
||||||
|
class FootnoteRefWidget extends WidgetType {
|
||||||
|
constructor(
|
||||||
|
readonly id: string,
|
||||||
|
readonly index: number,
|
||||||
|
readonly hasDefinition: boolean
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
toDOM(): HTMLElement {
|
||||||
|
const span = document.createElement('span');
|
||||||
|
span.className = 'cm-footnote-ref';
|
||||||
|
span.textContent = String(this.index);
|
||||||
|
span.dataset.footnoteId = this.id;
|
||||||
|
|
||||||
|
if (!this.hasDefinition) {
|
||||||
|
span.classList.add('cm-footnote-ref-undefined');
|
||||||
|
}
|
||||||
|
|
||||||
|
return span;
|
||||||
|
}
|
||||||
|
|
||||||
|
eq(other: FootnoteRefWidget): boolean {
|
||||||
|
return this.id === other.id && this.index === other.index;
|
||||||
|
}
|
||||||
|
|
||||||
|
ignoreEvent(): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Widget to display inline footnote as superscript.
|
||||||
|
*/
|
||||||
|
class InlineFootnoteWidget extends WidgetType {
|
||||||
|
constructor(
|
||||||
|
readonly content: string,
|
||||||
|
readonly index: number
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
toDOM(): HTMLElement {
|
||||||
|
const span = document.createElement('span');
|
||||||
|
span.className = 'cm-inline-footnote-ref';
|
||||||
|
span.textContent = String(this.index);
|
||||||
|
span.dataset.footnoteContent = this.content;
|
||||||
|
span.dataset.footnoteIndex = String(this.index);
|
||||||
|
|
||||||
|
return span;
|
||||||
|
}
|
||||||
|
|
||||||
|
eq(other: InlineFootnoteWidget): boolean {
|
||||||
|
return this.content === other.content && this.index === other.index;
|
||||||
|
}
|
||||||
|
|
||||||
|
ignoreEvent(): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Decorations
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build decorations for footnote references and inline footnotes.
|
||||||
|
*/
|
||||||
|
function buildDecorations(view: EditorView): DecorationSet {
|
||||||
|
const decorations: Range<Decoration>[] = [];
|
||||||
|
const data = view.state.field(footnoteDataField);
|
||||||
|
|
||||||
|
for (const { from, to } of view.visibleRanges) {
|
||||||
|
syntaxTree(view.state).iterate({
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
enter: ({ type, from: nodeFrom, to: nodeTo, node }) => {
|
||||||
|
// Handle footnote references
|
||||||
|
if (type.name === 'FootnoteReference') {
|
||||||
|
const cursorInRange = isCursorInRange(view.state, [nodeFrom, nodeTo]);
|
||||||
|
const labelNode = node.getChild('FootnoteReferenceLabel');
|
||||||
|
const marks = node.getChildren('FootnoteReferenceMark');
|
||||||
|
|
||||||
|
if (!labelNode || marks.length < 2) return;
|
||||||
|
|
||||||
|
const id = view.state.sliceDoc(labelNode.from, labelNode.to);
|
||||||
|
const ref = data.referencesByPos.get(nodeFrom);
|
||||||
|
|
||||||
|
if (!cursorInRange && ref && ref.id === id) {
|
||||||
|
// Hide the entire syntax and show widget
|
||||||
|
decorations.push(invisibleDecoration.range(nodeFrom, nodeTo));
|
||||||
|
|
||||||
|
// Add widget at the end
|
||||||
|
const widget = new FootnoteRefWidget(
|
||||||
|
id,
|
||||||
|
ref.index,
|
||||||
|
data.definitions.has(id)
|
||||||
|
);
|
||||||
|
decorations.push(
|
||||||
|
Decoration.widget({
|
||||||
|
widget,
|
||||||
|
side: 1,
|
||||||
|
}).range(nodeTo)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle footnote definitions
|
||||||
|
if (type.name === 'FootnoteDefinition') {
|
||||||
|
const cursorInRange = isCursorInRange(view.state, [nodeFrom, nodeTo]);
|
||||||
|
const marks = node.getChildren('FootnoteDefinitionMark');
|
||||||
|
const labelNode = node.getChild('FootnoteDefinitionLabel');
|
||||||
|
|
||||||
|
if (!cursorInRange && marks.length >= 2 && labelNode) {
|
||||||
|
// Hide the [^ and ]: marks
|
||||||
|
decorations.push(invisibleDecoration.range(marks[0].from, marks[0].to));
|
||||||
|
decorations.push(invisibleDecoration.range(marks[1].from, marks[1].to));
|
||||||
|
|
||||||
|
// Style the label as definition marker
|
||||||
|
decorations.push(
|
||||||
|
Decoration.mark({
|
||||||
|
class: 'cm-footnote-def-label',
|
||||||
|
}).range(labelNode.from, labelNode.to)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle inline footnotes
|
||||||
|
if (type.name === 'InlineFootnote') {
|
||||||
|
const cursorInRange = isCursorInRange(view.state, [nodeFrom, nodeTo]);
|
||||||
|
const contentNode = node.getChild('InlineFootnoteContent');
|
||||||
|
const marks = node.getChildren('InlineFootnoteMark');
|
||||||
|
|
||||||
|
if (!contentNode || marks.length < 2) return;
|
||||||
|
|
||||||
|
const inlineNote = data.inlineByPos.get(nodeFrom);
|
||||||
|
|
||||||
|
if (!cursorInRange && inlineNote) {
|
||||||
|
// Hide the entire syntax and show widget
|
||||||
|
decorations.push(invisibleDecoration.range(nodeFrom, nodeTo));
|
||||||
|
|
||||||
|
// Add widget at the end
|
||||||
|
const widget = new InlineFootnoteWidget(
|
||||||
|
inlineNote.content,
|
||||||
|
inlineNote.index
|
||||||
|
);
|
||||||
|
decorations.push(
|
||||||
|
Decoration.widget({
|
||||||
|
widget,
|
||||||
|
side: 1,
|
||||||
|
}).range(nodeTo)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return Decoration.set(decorations, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Plugin Class
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Footnote view plugin with optimized update detection.
|
||||||
|
*/
|
||||||
|
class FootnotePlugin {
|
||||||
|
decorations: DecorationSet;
|
||||||
|
private lastSelectionHead: number = -1;
|
||||||
|
|
||||||
|
constructor(view: EditorView) {
|
||||||
|
this.decorations = buildDecorations(view);
|
||||||
|
this.lastSelectionHead = view.state.selection.main.head;
|
||||||
|
}
|
||||||
|
|
||||||
|
update(update: ViewUpdate) {
|
||||||
|
if (update.docChanged || update.viewportChanged) {
|
||||||
|
this.decorations = buildDecorations(update.view);
|
||||||
|
this.lastSelectionHead = update.state.selection.main.head;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (update.selectionSet) {
|
||||||
|
const newHead = update.state.selection.main.head;
|
||||||
|
if (newHead !== this.lastSelectionHead) {
|
||||||
|
this.decorations = buildDecorations(update.view);
|
||||||
|
this.lastSelectionHead = newHead;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const footnotePlugin = ViewPlugin.fromClass(FootnotePlugin, {
|
||||||
|
decorations: (v) => v.decorations,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Hover Tooltip
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hover tooltip that shows footnote content.
|
||||||
|
*/
|
||||||
|
const footnoteHoverTooltip = hoverTooltip(
|
||||||
|
(view, pos): Tooltip | null => {
|
||||||
|
const data = view.state.field(footnoteDataField);
|
||||||
|
|
||||||
|
// Check if hovering over a footnote reference widget
|
||||||
|
const target = document.elementFromPoint(
|
||||||
|
view.coordsAtPos(pos)?.left ?? 0,
|
||||||
|
view.coordsAtPos(pos)?.top ?? 0
|
||||||
|
) as HTMLElement | null;
|
||||||
|
|
||||||
|
if (target?.classList.contains('cm-footnote-ref')) {
|
||||||
|
const id = target.dataset.footnoteId;
|
||||||
|
if (id) {
|
||||||
|
const def = data.definitions.get(id);
|
||||||
|
if (def) {
|
||||||
|
return {
|
||||||
|
pos,
|
||||||
|
above: true,
|
||||||
|
arrow: true,
|
||||||
|
create: () => createTooltipDom(id, def.content),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if hovering over an inline footnote widget
|
||||||
|
if (target?.classList.contains('cm-inline-footnote-ref')) {
|
||||||
|
const content = target.dataset.footnoteContent;
|
||||||
|
const index = target.dataset.footnoteIndex;
|
||||||
|
if (content && index) {
|
||||||
|
return {
|
||||||
|
pos,
|
||||||
|
above: true,
|
||||||
|
arrow: true,
|
||||||
|
create: () => createInlineTooltipDom(parseInt(index), content),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if position is within a footnote reference node
|
||||||
|
let foundId: string | null = null;
|
||||||
|
let foundPos: number = pos;
|
||||||
|
let foundInlineContent: string | null = null;
|
||||||
|
let foundInlineIndex: number | null = null;
|
||||||
|
|
||||||
|
syntaxTree(view.state).iterate({
|
||||||
|
from: pos,
|
||||||
|
to: pos,
|
||||||
|
enter: ({ type, from, to, node }) => {
|
||||||
|
if (type.name === 'FootnoteReference') {
|
||||||
|
const labelNode = node.getChild('FootnoteReferenceLabel');
|
||||||
|
if (labelNode && pos >= from && pos <= to) {
|
||||||
|
foundId = view.state.sliceDoc(labelNode.from, labelNode.to);
|
||||||
|
foundPos = to;
|
||||||
|
}
|
||||||
|
} else if (type.name === 'InlineFootnote') {
|
||||||
|
const contentNode = node.getChild('InlineFootnoteContent');
|
||||||
|
if (contentNode && pos >= from && pos <= to) {
|
||||||
|
foundInlineContent = view.state.sliceDoc(contentNode.from, contentNode.to);
|
||||||
|
const inlineNote = data.inlineByPos.get(from);
|
||||||
|
if (inlineNote) {
|
||||||
|
foundInlineIndex = inlineNote.index;
|
||||||
|
}
|
||||||
|
foundPos = to;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (foundId) {
|
||||||
|
const def = data.definitions.get(foundId);
|
||||||
|
if (def) {
|
||||||
|
const tooltipId = foundId;
|
||||||
|
const tooltipPos = foundPos;
|
||||||
|
return {
|
||||||
|
pos: tooltipPos,
|
||||||
|
above: true,
|
||||||
|
arrow: true,
|
||||||
|
create: () => createTooltipDom(tooltipId, def.content),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (foundInlineContent && foundInlineIndex !== null) {
|
||||||
|
const tooltipContent = foundInlineContent;
|
||||||
|
const tooltipIndex = foundInlineIndex;
|
||||||
|
const tooltipPos = foundPos;
|
||||||
|
return {
|
||||||
|
pos: tooltipPos,
|
||||||
|
above: true,
|
||||||
|
arrow: true,
|
||||||
|
create: () => createInlineTooltipDom(tooltipIndex, tooltipContent),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
{ hoverTime: 300 }
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create tooltip DOM element for regular footnote.
|
||||||
|
*/
|
||||||
|
function createTooltipDom(id: string, content: string): { dom: HTMLElement } {
|
||||||
|
const dom = document.createElement('div');
|
||||||
|
dom.className = 'cm-footnote-tooltip';
|
||||||
|
|
||||||
|
const header = document.createElement('div');
|
||||||
|
header.className = 'cm-footnote-tooltip-header';
|
||||||
|
header.textContent = `[^${id}]`;
|
||||||
|
|
||||||
|
const body = document.createElement('div');
|
||||||
|
body.className = 'cm-footnote-tooltip-body';
|
||||||
|
body.textContent = content || '(Empty footnote)';
|
||||||
|
|
||||||
|
dom.appendChild(header);
|
||||||
|
dom.appendChild(body);
|
||||||
|
|
||||||
|
return { dom };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create tooltip DOM element for inline footnote.
|
||||||
|
*/
|
||||||
|
function createInlineTooltipDom(index: number, content: string): { dom: HTMLElement } {
|
||||||
|
const dom = document.createElement('div');
|
||||||
|
dom.className = 'cm-footnote-tooltip';
|
||||||
|
|
||||||
|
const header = document.createElement('div');
|
||||||
|
header.className = 'cm-footnote-tooltip-header';
|
||||||
|
header.textContent = `Inline Footnote [${index}]`;
|
||||||
|
|
||||||
|
const body = document.createElement('div');
|
||||||
|
body.className = 'cm-footnote-tooltip-body';
|
||||||
|
body.textContent = content || '(Empty footnote)';
|
||||||
|
|
||||||
|
dom.appendChild(header);
|
||||||
|
dom.appendChild(body);
|
||||||
|
|
||||||
|
return { dom };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Click Handler
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Click handler for footnote navigation.
|
||||||
|
* Uses mousedown to intercept before editor moves cursor.
|
||||||
|
* - Click on reference → jump to definition
|
||||||
|
* - Click on definition label → jump to first reference
|
||||||
|
*/
|
||||||
|
const footnoteClickHandler = EditorView.domEventHandlers({
|
||||||
|
mousedown(event, view) {
|
||||||
|
const target = event.target as HTMLElement;
|
||||||
|
|
||||||
|
// Handle click on footnote reference widget
|
||||||
|
if (target.classList.contains('cm-footnote-ref')) {
|
||||||
|
const id = target.dataset.footnoteId;
|
||||||
|
if (id) {
|
||||||
|
const data = view.state.field(footnoteDataField);
|
||||||
|
const def = data.definitions.get(id);
|
||||||
|
if (def) {
|
||||||
|
// Prevent default to stop cursor from moving to widget position
|
||||||
|
event.preventDefault();
|
||||||
|
// Use setTimeout to dispatch after mousedown completes
|
||||||
|
setTimeout(() => {
|
||||||
|
view.dispatch({
|
||||||
|
selection: { anchor: def.from },
|
||||||
|
scrollIntoView: true,
|
||||||
|
});
|
||||||
|
view.focus();
|
||||||
|
}, 0);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle click on definition label
|
||||||
|
if (target.classList.contains('cm-footnote-def-label')) {
|
||||||
|
const pos = view.posAtDOM(target);
|
||||||
|
if (pos !== null) {
|
||||||
|
const data = view.state.field(footnoteDataField);
|
||||||
|
|
||||||
|
// Find which definition this belongs to
|
||||||
|
for (const [id, def] of data.definitions) {
|
||||||
|
if (pos >= def.from && pos <= def.to) {
|
||||||
|
// O(1) lookup for first reference
|
||||||
|
const firstRef = data.firstRefById.get(id);
|
||||||
|
if (firstRef) {
|
||||||
|
event.preventDefault();
|
||||||
|
setTimeout(() => {
|
||||||
|
view.dispatch({
|
||||||
|
selection: { anchor: firstRef.from },
|
||||||
|
scrollIntoView: true,
|
||||||
|
});
|
||||||
|
view.focus();
|
||||||
|
}, 0);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Theme
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base theme for footnotes.
|
||||||
|
*/
|
||||||
|
const baseTheme = EditorView.baseTheme({
|
||||||
|
// Footnote reference (superscript)
|
||||||
|
'.cm-footnote-ref': {
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
minWidth: '1em',
|
||||||
|
height: '1.2em',
|
||||||
|
padding: '0 0.25em',
|
||||||
|
marginLeft: '1px',
|
||||||
|
fontSize: '0.75em',
|
||||||
|
fontWeight: '500',
|
||||||
|
lineHeight: '1',
|
||||||
|
verticalAlign: 'super',
|
||||||
|
color: 'var(--cm-footnote-color, #1a73e8)',
|
||||||
|
backgroundColor: 'var(--cm-footnote-bg, rgba(26, 115, 232, 0.1))',
|
||||||
|
borderRadius: '3px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.15s ease',
|
||||||
|
textDecoration: 'none',
|
||||||
|
},
|
||||||
|
'.cm-footnote-ref:hover': {
|
||||||
|
color: 'var(--cm-footnote-hover-color, #1557b0)',
|
||||||
|
backgroundColor: 'var(--cm-footnote-hover-bg, rgba(26, 115, 232, 0.2))',
|
||||||
|
},
|
||||||
|
'.cm-footnote-ref-undefined': {
|
||||||
|
color: 'var(--cm-footnote-undefined-color, #d93025)',
|
||||||
|
backgroundColor: 'var(--cm-footnote-undefined-bg, rgba(217, 48, 37, 0.1))',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Inline footnote reference (superscript) - uses distinct color
|
||||||
|
'.cm-inline-footnote-ref': {
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
minWidth: '1em',
|
||||||
|
height: '1.2em',
|
||||||
|
padding: '0 0.25em',
|
||||||
|
marginLeft: '1px',
|
||||||
|
fontSize: '0.75em',
|
||||||
|
fontWeight: '500',
|
||||||
|
lineHeight: '1',
|
||||||
|
verticalAlign: 'super',
|
||||||
|
color: 'var(--cm-inline-footnote-color, #e67e22)',
|
||||||
|
backgroundColor: 'var(--cm-inline-footnote-bg, rgba(230, 126, 34, 0.1))',
|
||||||
|
borderRadius: '3px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.15s ease',
|
||||||
|
textDecoration: 'none',
|
||||||
|
},
|
||||||
|
'.cm-inline-footnote-ref:hover': {
|
||||||
|
color: 'var(--cm-inline-footnote-hover-color, #d35400)',
|
||||||
|
backgroundColor: 'var(--cm-inline-footnote-hover-bg, rgba(230, 126, 34, 0.2))',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Footnote definition label
|
||||||
|
'.cm-footnote-def-label': {
|
||||||
|
color: 'var(--cm-footnote-def-color, #1a73e8)',
|
||||||
|
fontWeight: '600',
|
||||||
|
cursor: 'pointer',
|
||||||
|
},
|
||||||
|
'.cm-footnote-def-label:hover': {
|
||||||
|
textDecoration: 'underline',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Tooltip
|
||||||
|
'.cm-footnote-tooltip': {
|
||||||
|
maxWidth: '400px',
|
||||||
|
padding: '0',
|
||||||
|
backgroundColor: 'var(--bg-secondary)',
|
||||||
|
border: '1px solid var(--border-color)',
|
||||||
|
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
'.cm-footnote-tooltip-header': {
|
||||||
|
padding: '6px 12px',
|
||||||
|
fontSize: '0.8em',
|
||||||
|
fontWeight: '600',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
color: 'var(--cm-footnote-color, #1a73e8)',
|
||||||
|
backgroundColor: 'var(--bg-tertiary, rgba(0, 0, 0, 0.05))',
|
||||||
|
borderBottom: '1px solid var(--border-color)',
|
||||||
|
},
|
||||||
|
'.cm-footnote-tooltip-body': {
|
||||||
|
padding: '10px 12px',
|
||||||
|
fontSize: '0.9em',
|
||||||
|
lineHeight: '1.5',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Tooltip animation
|
||||||
|
'.cm-tooltip:has(.cm-footnote-tooltip)': {
|
||||||
|
animation: 'cm-footnote-fade-in 0.15s ease-out',
|
||||||
|
},
|
||||||
|
'@keyframes cm-footnote-fade-in': {
|
||||||
|
from: { opacity: '0', transform: 'translateY(4px)' },
|
||||||
|
to: { opacity: '1', transform: 'translateY(0)' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Export
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Footnote extension.
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Parses footnote references [^id] and definitions [^id]: content
|
||||||
|
* - Parses inline footnotes ^[content]
|
||||||
|
* - Renders references and inline footnotes as superscript numbers
|
||||||
|
* - Shows definition/content on hover
|
||||||
|
* - Click to navigate between reference and definition
|
||||||
|
*/
|
||||||
|
export const footnote = (): Extension => [
|
||||||
|
footnoteDataField,
|
||||||
|
footnotePlugin,
|
||||||
|
footnoteHoverTooltip,
|
||||||
|
footnoteClickHandler,
|
||||||
|
baseTheme,
|
||||||
|
];
|
||||||
|
|
||||||
|
export default footnote;
|
||||||
|
|
||||||
@@ -1,73 +1,177 @@
|
|||||||
|
import { syntaxTree } from '@codemirror/language';
|
||||||
import { Extension, Range } from '@codemirror/state';
|
import { Extension, Range } from '@codemirror/state';
|
||||||
import { EditorView } from 'codemirror';
|
|
||||||
import { imagePreview } from '../state/image';
|
|
||||||
import { image as classes } from '../classes';
|
|
||||||
import {
|
import {
|
||||||
Decoration,
|
|
||||||
DecorationSet,
|
DecorationSet,
|
||||||
|
Decoration,
|
||||||
|
WidgetType,
|
||||||
|
EditorView,
|
||||||
ViewPlugin,
|
ViewPlugin,
|
||||||
ViewUpdate
|
ViewUpdate,
|
||||||
|
hoverTooltip,
|
||||||
|
Tooltip
|
||||||
} from '@codemirror/view';
|
} from '@codemirror/view';
|
||||||
import {
|
|
||||||
iterateTreeInVisibleRanges,
|
|
||||||
isCursorInRange,
|
|
||||||
invisibleDecoration
|
|
||||||
} from '../util';
|
|
||||||
|
|
||||||
/**
|
interface ImageInfo {
|
||||||
* Build decorations to hide image markdown syntax.
|
src: string;
|
||||||
* Only hides when cursor is outside the image range.
|
from: number;
|
||||||
*/
|
to: number;
|
||||||
function hideImageNodes(view: EditorView) {
|
alt: string;
|
||||||
const widgets = new Array<Range<Decoration>>();
|
|
||||||
iterateTreeInVisibleRanges(view, {
|
|
||||||
enter(node) {
|
|
||||||
if (
|
|
||||||
node.name === 'Image' &&
|
|
||||||
!isCursorInRange(view.state, [node.from, node.to])
|
|
||||||
) {
|
|
||||||
widgets.push(invisibleDecoration.range(node.from, node.to));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return Decoration.set(widgets, true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
const IMAGE_EXT_RE = /\.(png|jpe?g|gif|webp|svg|bmp|ico|avif|apng|tiff?)(\?.*)?$/i;
|
||||||
* Plugin to hide image markdown syntax when cursor is outside.
|
const IMAGE_ALT_RE = /(?:!\[)(.*?)(?:\])/;
|
||||||
*/
|
const ICON = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>`;
|
||||||
const hideImageNodePlugin = ViewPlugin.fromClass(
|
|
||||||
class {
|
function isImageUrl(url: string): boolean {
|
||||||
|
return IMAGE_EXT_RE.test(url) || url.startsWith('data:image/');
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractImages(view: EditorView): ImageInfo[] {
|
||||||
|
const result: ImageInfo[] = [];
|
||||||
|
for (const { from, to } of view.visibleRanges) {
|
||||||
|
syntaxTree(view.state).iterate({
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
enter: ({ name, node, from: f, to: t }) => {
|
||||||
|
if (name !== 'Image') return;
|
||||||
|
const urlNode = node.getChild('URL');
|
||||||
|
if (!urlNode) return;
|
||||||
|
const src = view.state.sliceDoc(urlNode.from, urlNode.to);
|
||||||
|
if (!isImageUrl(src)) return;
|
||||||
|
const text = view.state.sliceDoc(f, t);
|
||||||
|
const alt = text.match(IMAGE_ALT_RE)?.[1] ?? '';
|
||||||
|
result.push({ src, from: f, to: t, alt });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
class IndicatorWidget extends WidgetType {
|
||||||
|
constructor(readonly info: ImageInfo) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
toDOM(): HTMLElement {
|
||||||
|
const el = document.createElement('span');
|
||||||
|
el.className = 'cm-image-indicator';
|
||||||
|
el.innerHTML = ICON;
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
eq(other: IndicatorWidget): boolean {
|
||||||
|
return this.info.from === other.info.from && this.info.src === other.info.src;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ImagePlugin {
|
||||||
decorations: DecorationSet;
|
decorations: DecorationSet;
|
||||||
|
images: ImageInfo[] = [];
|
||||||
|
|
||||||
constructor(view: EditorView) {
|
constructor(view: EditorView) {
|
||||||
this.decorations = hideImageNodes(view);
|
this.images = extractImages(view);
|
||||||
|
this.decorations = this.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
update(update: ViewUpdate) {
|
update(update: ViewUpdate) {
|
||||||
if (update.docChanged || update.selectionSet || update.viewportChanged) {
|
if (update.docChanged || update.viewportChanged) {
|
||||||
this.decorations = hideImageNodes(update.view);
|
this.images = extractImages(update.view);
|
||||||
|
this.decorations = this.build();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private build(): DecorationSet {
|
||||||
|
const deco: Range<Decoration>[] = [];
|
||||||
|
for (const img of this.images) {
|
||||||
|
deco.push(Decoration.widget({ widget: new IndicatorWidget(img), side: 1 }).range(img.to));
|
||||||
|
}
|
||||||
|
return Decoration.set(deco, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
getImageAt(pos: number): ImageInfo | null {
|
||||||
|
for (const img of this.images) {
|
||||||
|
if (pos >= img.to && pos <= img.to + 1) {
|
||||||
|
return img;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const imagePlugin = ViewPlugin.fromClass(ImagePlugin, {
|
||||||
|
decorations: (v) => v.decorations
|
||||||
|
});
|
||||||
|
|
||||||
|
const imageHoverTooltip = hoverTooltip(
|
||||||
|
(view, pos): Tooltip | null => {
|
||||||
|
const plugin = view.plugin(imagePlugin);
|
||||||
|
if (!plugin) return null;
|
||||||
|
|
||||||
|
const img = plugin.getImageAt(pos);
|
||||||
|
if (!img) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
pos: img.to,
|
||||||
|
above: true,
|
||||||
|
arrow: true,
|
||||||
|
create: () => {
|
||||||
|
const dom = document.createElement('div');
|
||||||
|
dom.className = 'cm-image-tooltip';
|
||||||
|
|
||||||
|
const imgEl = document.createElement('img');
|
||||||
|
imgEl.src = img.src;
|
||||||
|
imgEl.alt = img.alt;
|
||||||
|
|
||||||
|
imgEl.onerror = () => {
|
||||||
|
imgEl.remove();
|
||||||
|
dom.textContent = 'Failed to load image';
|
||||||
|
dom.classList.add('cm-image-tooltip-error');
|
||||||
|
};
|
||||||
|
|
||||||
|
dom.append(imgEl);
|
||||||
|
return { dom };
|
||||||
|
}
|
||||||
|
};
|
||||||
},
|
},
|
||||||
{ decorations: (v) => v.decorations }
|
{ hoverTime: 300 }
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
const theme = EditorView.baseTheme({
|
||||||
* Image plugin.
|
'.cm-image-indicator': {
|
||||||
*/
|
display: 'inline-flex',
|
||||||
export const image = (): Extension => [
|
alignItems: 'center',
|
||||||
imagePreview(),
|
marginLeft: '4px',
|
||||||
hideImageNodePlugin,
|
verticalAlign: 'middle',
|
||||||
baseTheme
|
cursor: 'pointer',
|
||||||
];
|
opacity: '0.5',
|
||||||
|
color: 'var(--cm-link-color, #1a73e8)',
|
||||||
const baseTheme = EditorView.baseTheme({
|
transition: 'opacity 0.15s',
|
||||||
['.' + classes.widget]: {
|
'& svg': { width: '14px', height: '14px' }
|
||||||
|
},
|
||||||
|
'.cm-image-indicator:hover': { opacity: '1' },
|
||||||
|
'.cm-image-tooltip': {
|
||||||
|
background: 'var(--bg-secondary)',
|
||||||
|
border: '1px solid var(--border-color)',
|
||||||
|
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
|
||||||
|
'& img': {
|
||||||
display: 'block',
|
display: 'block',
|
||||||
objectFit: 'contain',
|
maxWidth: '60vw',
|
||||||
maxWidth: '100%',
|
maxHeight: '50vh'
|
||||||
maxHeight: '100%',
|
}
|
||||||
userSelect: 'none'
|
},
|
||||||
|
'.cm-image-tooltip-error': {
|
||||||
|
padding: '16px 24px',
|
||||||
|
fontSize: '12px',
|
||||||
|
color: 'var(--text-muted)'
|
||||||
|
},
|
||||||
|
'.cm-tooltip-arrow:before': {
|
||||||
|
borderTopColor: 'var(--border-color) !important',
|
||||||
|
borderBottomColor: 'var(--border-color) !important'
|
||||||
|
},
|
||||||
|
'.cm-tooltip-arrow:after': {
|
||||||
|
borderTopColor: 'var(--bg-secondary) !important',
|
||||||
|
borderBottomColor: 'var(--bg-secondary) !important'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const image = (): Extension => [imagePlugin, imageHoverTooltip, theme];
|
||||||
|
|||||||
@@ -105,8 +105,7 @@ const baseTheme = EditorView.baseTheme({
|
|||||||
backgroundColor: 'var(--cm-inline-code-bg)',
|
backgroundColor: 'var(--cm-inline-code-bg)',
|
||||||
borderRadius: '0.25rem',
|
borderRadius: '0.25rem',
|
||||||
padding: '0.1rem 0.3rem',
|
padding: '0.1rem 0.3rem',
|
||||||
fontFamily: 'var(--voidraft-font-mono)',
|
fontFamily: 'var(--voidraft-font-mono)'
|
||||||
fontSize: '0.9em'
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -16,8 +16,10 @@ const AUTO_LINK_MARK_RE = /^<|>$/g;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Parent node types that should not process.
|
* Parent node types that should not process.
|
||||||
|
* - Image: handled by image plugin
|
||||||
|
* - LinkReference: reference link definitions like [label]: url should be fully visible
|
||||||
*/
|
*/
|
||||||
const BLACKLISTED_PARENTS = new Set(['Image']);
|
const BLACKLISTED_PARENTS = new Set(['Image', 'LinkReference']);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Links plugin.
|
* Links plugin.
|
||||||
@@ -50,6 +52,19 @@ function buildLinkDecorations(view: EditorView): DecorationSet {
|
|||||||
const marks = parent.getChildren('LinkMark');
|
const marks = parent.getChildren('LinkMark');
|
||||||
const linkTitle = parent.getChild('LinkTitle');
|
const linkTitle = parent.getChild('LinkTitle');
|
||||||
|
|
||||||
|
// Find the ']' mark position to distinguish between link text and link target
|
||||||
|
// Link structure: [display text](url)
|
||||||
|
// We should only hide the URL in the () part, not in the [] part
|
||||||
|
const closeBracketMark = marks.find((mark) => {
|
||||||
|
const text = view.state.sliceDoc(mark.from, mark.to);
|
||||||
|
return text === ']';
|
||||||
|
});
|
||||||
|
|
||||||
|
// If URL is before ']', it's part of the display text, don't hide it
|
||||||
|
if (closeBracketMark && nodeFrom < closeBracketMark.from) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Check if cursor overlaps with the link
|
// Check if cursor overlaps with the link
|
||||||
const cursorOverlaps = selectionRanges.some((range) =>
|
const cursorOverlaps = selectionRanges.some((range) =>
|
||||||
checkRangeOverlap([range.from, range.to], [parent.from, parent.to])
|
checkRangeOverlap([range.from, range.to], [parent.from, parent.to])
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
import { Range, StateField, Transaction } from '@codemirror/state';
|
import { Range, StateField, Transaction } from '@codemirror/state';
|
||||||
import { syntaxTree } from '@codemirror/language';
|
import { syntaxTree } from '@codemirror/language';
|
||||||
import { isCursorInRange } from '../util';
|
import { isCursorInRange } from '../util';
|
||||||
import { list as classes } from '../classes';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pattern for bullet list markers.
|
* Pattern for bullet list markers.
|
||||||
@@ -43,7 +42,7 @@ class ListBulletWidget extends WidgetType {
|
|||||||
|
|
||||||
toDOM(): HTMLElement {
|
toDOM(): HTMLElement {
|
||||||
const span = document.createElement('span');
|
const span = document.createElement('span');
|
||||||
span.className = classes.bullet;
|
span.className = 'cm-list-bullet';
|
||||||
span.textContent = '•';
|
span.textContent = '•';
|
||||||
return span;
|
return span;
|
||||||
}
|
}
|
||||||
@@ -145,7 +144,7 @@ class TaskCheckboxWidget extends WidgetType {
|
|||||||
toDOM(view: EditorView): HTMLElement {
|
toDOM(view: EditorView): HTMLElement {
|
||||||
const wrap = document.createElement('span');
|
const wrap = document.createElement('span');
|
||||||
wrap.setAttribute('aria-hidden', 'true');
|
wrap.setAttribute('aria-hidden', 'true');
|
||||||
wrap.className = classes.taskCheckbox;
|
wrap.className = 'cm-task-checkbox';
|
||||||
|
|
||||||
const checkbox = document.createElement('input');
|
const checkbox = document.createElement('input');
|
||||||
checkbox.type = 'checkbox';
|
checkbox.type = 'checkbox';
|
||||||
@@ -205,12 +204,9 @@ function buildTaskListDecorations(state: import('@codemirror/state').EditorState
|
|||||||
const isChecked = markerText.length >= 2 && 'xX'.includes(markerText[1]);
|
const isChecked = markerText.length >= 2 && 'xX'.includes(markerText[1]);
|
||||||
const checkboxPos = taskMarker.from + 1; // Position of the x or space
|
const checkboxPos = taskMarker.from + 1; // Position of the x or space
|
||||||
|
|
||||||
// Add checked style to the entire task content
|
|
||||||
if (isChecked) {
|
if (isChecked) {
|
||||||
decorations.push(
|
decorations.push(
|
||||||
Decoration.mark({
|
Decoration.mark({ class: 'cm-task-checked' }).range(taskFrom, taskTo)
|
||||||
class: classes.taskChecked
|
|
||||||
}).range(taskFrom, taskTo)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,19 +251,18 @@ const taskListField = StateField.define<DecorationSet>({
|
|||||||
* Base theme for lists.
|
* Base theme for lists.
|
||||||
*/
|
*/
|
||||||
const baseTheme = EditorView.baseTheme({
|
const baseTheme = EditorView.baseTheme({
|
||||||
[`.${classes.bullet}`]: {
|
'.cm-list-bullet': {
|
||||||
// No extra width - just replace the character
|
|
||||||
color: 'var(--cm-list-bullet-color, inherit)'
|
color: 'var(--cm-list-bullet-color, inherit)'
|
||||||
},
|
},
|
||||||
[`.${classes.taskChecked}`]: {
|
'.cm-task-checked': {
|
||||||
textDecoration: 'line-through',
|
textDecoration: 'line-through',
|
||||||
opacity: '0.6'
|
opacity: '0.6'
|
||||||
},
|
},
|
||||||
[`.${classes.taskCheckbox}`]: {
|
'.cm-task-checkbox': {
|
||||||
display: 'inline-block',
|
display: 'inline-block',
|
||||||
verticalAlign: 'baseline'
|
verticalAlign: 'baseline'
|
||||||
},
|
},
|
||||||
[`.${classes.taskCheckbox} input`]: {
|
'.cm-task-checkbox input': {
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
margin: '0',
|
margin: '0',
|
||||||
width: '1em',
|
width: '1em',
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ import { isCursorInRange, invisibleDecoration } from '../util';
|
|||||||
* - Superscript: ^text^ → renders as superscript
|
* - Superscript: ^text^ → renders as superscript
|
||||||
* - Subscript: ~text~ → renders as subscript
|
* - Subscript: ~text~ → renders as subscript
|
||||||
*
|
*
|
||||||
|
* Note: Inline footnotes ^[content] are handled by the Footnote extension
|
||||||
|
* which parses InlineFootnote before Superscript in the syntax tree.
|
||||||
|
*
|
||||||
* Examples:
|
* Examples:
|
||||||
* - 19^th^ → 19ᵗʰ (superscript)
|
* - 19^th^ → 19ᵗʰ (superscript)
|
||||||
* - H~2~O → H₂O (subscript)
|
* - H~2~O → H₂O (subscript)
|
||||||
@@ -37,30 +40,15 @@ function buildDecorations(view: EditorView): DecorationSet {
|
|||||||
to,
|
to,
|
||||||
enter: ({ type, from: nodeFrom, to: nodeTo, node }) => {
|
enter: ({ type, from: nodeFrom, to: nodeTo, node }) => {
|
||||||
// Handle Superscript nodes
|
// Handle Superscript nodes
|
||||||
|
// Note: InlineFootnote ^[content] is parsed before Superscript,
|
||||||
|
// so we don't need to check for bracket patterns here
|
||||||
if (type.name === 'Superscript') {
|
if (type.name === 'Superscript') {
|
||||||
// Get the full content including marks
|
|
||||||
const fullContent = view.state.doc.sliceString(nodeFrom, nodeTo);
|
|
||||||
|
|
||||||
// Skip if this contains inline footnote pattern ^[
|
|
||||||
// This catches ^[text] being misinterpreted as superscript
|
|
||||||
if (fullContent.includes('^[') || fullContent.includes('[') && fullContent.includes(']')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cursorInRange = isCursorInRange(view.state, [nodeFrom, nodeTo]);
|
const cursorInRange = isCursorInRange(view.state, [nodeFrom, nodeTo]);
|
||||||
|
|
||||||
// Get the mark nodes (the ^ characters)
|
// Get the mark nodes (the ^ characters)
|
||||||
const marks = node.getChildren('SuperscriptMark');
|
const marks = node.getChildren('SuperscriptMark');
|
||||||
|
|
||||||
if (!cursorInRange && marks.length >= 2) {
|
if (!cursorInRange && marks.length >= 2) {
|
||||||
// Get inner content between marks
|
|
||||||
const innerContent = view.state.doc.sliceString(marks[0].to, marks[marks.length - 1].from);
|
|
||||||
|
|
||||||
// Skip if inner content looks like footnote (starts with [ or contains brackets)
|
|
||||||
if (innerContent.startsWith('[') || innerContent.includes('[') || innerContent.includes(']')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hide the opening and closing ^ marks
|
// Hide the opening and closing ^ marks
|
||||||
decorations.push(invisibleDecoration.range(marks[0].from, marks[0].to));
|
decorations.push(invisibleDecoration.range(marks[0].from, marks[0].to));
|
||||||
decorations.push(invisibleDecoration.range(marks[marks.length - 1].from, marks[marks.length - 1].to));
|
decorations.push(invisibleDecoration.range(marks[marks.length - 1].from, marks[marks.length - 1].to));
|
||||||
@@ -148,16 +136,17 @@ const subscriptSuperscriptPlugin = ViewPlugin.fromClass(
|
|||||||
/**
|
/**
|
||||||
* Base theme for subscript and superscript.
|
* Base theme for subscript and superscript.
|
||||||
* Uses mark decoration instead of widget to avoid layout issues.
|
* Uses mark decoration instead of widget to avoid layout issues.
|
||||||
|
* fontSize uses smaller value as subscript/superscript are naturally smaller.
|
||||||
*/
|
*/
|
||||||
const baseTheme = EditorView.baseTheme({
|
const baseTheme = EditorView.baseTheme({
|
||||||
'.cm-superscript': {
|
'.cm-superscript': {
|
||||||
verticalAlign: 'super',
|
verticalAlign: 'super',
|
||||||
fontSize: '0.8em',
|
fontSize: '0.75em',
|
||||||
color: 'var(--cm-superscript-color, inherit)'
|
color: 'var(--cm-superscript-color, inherit)'
|
||||||
},
|
},
|
||||||
'.cm-subscript': {
|
'.cm-subscript': {
|
||||||
verticalAlign: 'sub',
|
verticalAlign: 'sub',
|
||||||
fontSize: '0.8em',
|
fontSize: '0.75em',
|
||||||
color: 'var(--cm-subscript-color, inherit)'
|
color: 'var(--cm-subscript-color, inherit)'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,145 +0,0 @@
|
|||||||
import { Extension, Range } from '@codemirror/state';
|
|
||||||
import {
|
|
||||||
ViewPlugin,
|
|
||||||
DecorationSet,
|
|
||||||
Decoration,
|
|
||||||
EditorView,
|
|
||||||
ViewUpdate
|
|
||||||
} from '@codemirror/view';
|
|
||||||
import { isCursorInRange } from '../util';
|
|
||||||
import { extractTablesFromState } from '../state/table';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Table styling extension for Markdown.
|
|
||||||
*
|
|
||||||
* - Adds background styling and hides syntax when cursor is OUTSIDE table
|
|
||||||
* - Shows raw markdown with NO styling when cursor is INSIDE table (edit mode)
|
|
||||||
*/
|
|
||||||
export const table = (): Extension => [tablePlugin, baseTheme];
|
|
||||||
|
|
||||||
/** Line decorations - only applied when NOT editing */
|
|
||||||
const headerLine = Decoration.line({ attributes: { class: 'cm-table-header' } });
|
|
||||||
const delimiterLine = Decoration.line({ attributes: { class: 'cm-table-delimiter' } });
|
|
||||||
const dataLine = Decoration.line({ attributes: { class: 'cm-table-data' } });
|
|
||||||
|
|
||||||
/** Mark to hide pipe characters */
|
|
||||||
const pipeHidden = Decoration.mark({ attributes: { class: 'cm-table-pipe-hidden' } });
|
|
||||||
|
|
||||||
/** Mark to hide delimiter row content */
|
|
||||||
const delimiterHidden = Decoration.mark({ attributes: { class: 'cm-table-delimiter-hidden' } });
|
|
||||||
|
|
||||||
/** Delimiter row regex */
|
|
||||||
const DELIMITER_REGEX = /^\s*\|?\s*[-:]+/;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build decorations for tables.
|
|
||||||
* Only adds decorations when cursor is OUTSIDE the table.
|
|
||||||
*/
|
|
||||||
function buildDecorations(view: EditorView): DecorationSet {
|
|
||||||
const decorations: Range<Decoration>[] = [];
|
|
||||||
const tables = extractTablesFromState(view.state);
|
|
||||||
|
|
||||||
for (const table of tables) {
|
|
||||||
// Skip all decorations if cursor is inside table (edit mode)
|
|
||||||
if (isCursorInRange(view.state, [table.from, table.to])) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const startLine = view.state.doc.lineAt(table.from);
|
|
||||||
const endLine = view.state.doc.lineAt(table.to);
|
|
||||||
const lines = table.rawText.split('\n');
|
|
||||||
|
|
||||||
for (let i = 0; i < lines.length; i++) {
|
|
||||||
const lineNum = startLine.number + i;
|
|
||||||
if (lineNum > endLine.number) break;
|
|
||||||
|
|
||||||
const line = view.state.doc.line(lineNum);
|
|
||||||
const text = lines[i];
|
|
||||||
const isDelimiter = i === 1 && DELIMITER_REGEX.test(text);
|
|
||||||
|
|
||||||
// Add line decoration
|
|
||||||
if (i === 0) {
|
|
||||||
decorations.push(headerLine.range(line.from));
|
|
||||||
} else if (isDelimiter) {
|
|
||||||
decorations.push(delimiterLine.range(line.from));
|
|
||||||
} else {
|
|
||||||
decorations.push(dataLine.range(line.from));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hide syntax elements
|
|
||||||
if (isDelimiter) {
|
|
||||||
// Hide entire delimiter content
|
|
||||||
decorations.push(delimiterHidden.range(line.from, line.to));
|
|
||||||
} else {
|
|
||||||
// Hide pipe characters
|
|
||||||
for (let j = 0; j < text.length; j++) {
|
|
||||||
if (text[j] === '|') {
|
|
||||||
decorations.push(pipeHidden.range(line.from + j, line.from + j + 1));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Decoration.set(decorations, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Table ViewPlugin.
|
|
||||||
*/
|
|
||||||
const tablePlugin = ViewPlugin.fromClass(
|
|
||||||
class {
|
|
||||||
decorations: DecorationSet;
|
|
||||||
|
|
||||||
constructor(view: EditorView) {
|
|
||||||
this.decorations = buildDecorations(view);
|
|
||||||
}
|
|
||||||
|
|
||||||
update(update: ViewUpdate) {
|
|
||||||
if (update.docChanged || update.selectionSet || update.viewportChanged) {
|
|
||||||
this.decorations = buildDecorations(update.view);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ decorations: v => v.decorations }
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Base theme for table styling.
|
|
||||||
*/
|
|
||||||
const baseTheme = EditorView.baseTheme({
|
|
||||||
// Header row
|
|
||||||
'.cm-table-header': {
|
|
||||||
backgroundColor: 'var(--cm-table-header-bg, rgba(128, 128, 128, 0.12))',
|
|
||||||
borderTopLeftRadius: '4px',
|
|
||||||
borderTopRightRadius: '4px'
|
|
||||||
},
|
|
||||||
|
|
||||||
// Delimiter row
|
|
||||||
'.cm-table-delimiter': {
|
|
||||||
backgroundColor: 'var(--cm-table-bg, rgba(128, 128, 128, 0.06))',
|
|
||||||
lineHeight: '0.5'
|
|
||||||
},
|
|
||||||
|
|
||||||
// Data rows
|
|
||||||
'.cm-table-data': {
|
|
||||||
backgroundColor: 'var(--cm-table-bg, rgba(128, 128, 128, 0.06))'
|
|
||||||
},
|
|
||||||
|
|
||||||
'.cm-table-data:last-of-type': {
|
|
||||||
borderBottomLeftRadius: '4px',
|
|
||||||
borderBottomRightRadius: '4px'
|
|
||||||
},
|
|
||||||
|
|
||||||
// Hidden pipe characters
|
|
||||||
'.cm-table-pipe-hidden': {
|
|
||||||
fontSize: '0',
|
|
||||||
color: 'transparent'
|
|
||||||
},
|
|
||||||
|
|
||||||
// Hidden delimiter content
|
|
||||||
'.cm-table-delimiter-hidden': {
|
|
||||||
fontSize: '0',
|
|
||||||
color: 'transparent'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,209 +0,0 @@
|
|||||||
import { syntaxTree } from '@codemirror/language';
|
|
||||||
import { Extension, Range } from '@codemirror/state';
|
|
||||||
import {
|
|
||||||
DecorationSet,
|
|
||||||
Decoration,
|
|
||||||
WidgetType,
|
|
||||||
EditorView,
|
|
||||||
ViewPlugin,
|
|
||||||
ViewUpdate
|
|
||||||
} from '@codemirror/view';
|
|
||||||
import { isCursorInRange } from '../util';
|
|
||||||
import { image as classes } from '../classes';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Representation of image data extracted from the syntax tree.
|
|
||||||
*/
|
|
||||||
export interface ImageInfo {
|
|
||||||
/** The source URL of the image. */
|
|
||||||
src: string;
|
|
||||||
/** The starting position of the image element in the document. */
|
|
||||||
from: number;
|
|
||||||
/** The end position of the image element in the document. */
|
|
||||||
to: number;
|
|
||||||
/** The alt text of the image. */
|
|
||||||
alt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Capture everything in square brackets of a markdown image, after
|
|
||||||
* the exclamation mark.
|
|
||||||
*/
|
|
||||||
const IMAGE_TEXT_RE = /(?:!\[)(.*?)(?:\])/;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract images from the syntax tree.
|
|
||||||
*/
|
|
||||||
function extractImages(view: EditorView): ImageInfo[] {
|
|
||||||
const images: ImageInfo[] = [];
|
|
||||||
|
|
||||||
for (const { from, to } of view.visibleRanges) {
|
|
||||||
syntaxTree(view.state).iterate({
|
|
||||||
from,
|
|
||||||
to,
|
|
||||||
enter: ({ name, node, from: nodeFrom, to: nodeTo }) => {
|
|
||||||
if (name !== 'Image') return;
|
|
||||||
const altMatch = view.state.sliceDoc(nodeFrom, nodeTo).match(IMAGE_TEXT_RE);
|
|
||||||
const alt: string = altMatch?.pop() ?? '';
|
|
||||||
const urlNode = node.getChild('URL');
|
|
||||||
if (urlNode) {
|
|
||||||
const url: string = view.state.sliceDoc(urlNode.from, urlNode.to);
|
|
||||||
images.push({ src: url, from: nodeFrom, to: nodeTo, alt });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return images;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build image preview decorations.
|
|
||||||
* Only shows preview when cursor is outside the image syntax.
|
|
||||||
*/
|
|
||||||
function buildImageDecorations(view: EditorView, loadedImages: Set<string>): DecorationSet {
|
|
||||||
const decorations: Range<Decoration>[] = [];
|
|
||||||
const images = extractImages(view);
|
|
||||||
|
|
||||||
for (const img of images) {
|
|
||||||
const cursorInImage = isCursorInRange(view.state, [img.from, img.to]);
|
|
||||||
|
|
||||||
// Only show preview when cursor is outside
|
|
||||||
if (!cursorInImage) {
|
|
||||||
const isLoaded = loadedImages.has(img.src);
|
|
||||||
decorations.push(
|
|
||||||
Decoration.widget({
|
|
||||||
widget: new ImagePreviewWidget(img, isLoaded, loadedImages),
|
|
||||||
side: 1
|
|
||||||
}).range(img.to)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Decoration.set(decorations, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Image preview widget that displays the actual image.
|
|
||||||
*/
|
|
||||||
class ImagePreviewWidget extends WidgetType {
|
|
||||||
constructor(
|
|
||||||
private readonly info: ImageInfo,
|
|
||||||
private readonly isLoaded: boolean,
|
|
||||||
private readonly loadedImages: Set<string>
|
|
||||||
) {
|
|
||||||
super();
|
|
||||||
}
|
|
||||||
|
|
||||||
toDOM(view: EditorView): HTMLElement {
|
|
||||||
const wrapper = document.createElement('span');
|
|
||||||
wrapper.className = 'cm-image-preview-wrapper';
|
|
||||||
|
|
||||||
const img = new Image();
|
|
||||||
img.classList.add(classes.widget);
|
|
||||||
img.src = this.info.src;
|
|
||||||
img.alt = this.info.alt;
|
|
||||||
|
|
||||||
if (!this.isLoaded) {
|
|
||||||
img.addEventListener('load', () => {
|
|
||||||
this.loadedImages.add(this.info.src);
|
|
||||||
view.dispatch({});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.isLoaded) {
|
|
||||||
wrapper.appendChild(img);
|
|
||||||
} else {
|
|
||||||
const placeholder = document.createElement('span');
|
|
||||||
placeholder.className = 'cm-image-loading';
|
|
||||||
placeholder.textContent = '🖼️';
|
|
||||||
wrapper.appendChild(placeholder);
|
|
||||||
img.style.display = 'none';
|
|
||||||
wrapper.appendChild(img);
|
|
||||||
}
|
|
||||||
|
|
||||||
return wrapper;
|
|
||||||
}
|
|
||||||
|
|
||||||
eq(widget: ImagePreviewWidget): boolean {
|
|
||||||
return (
|
|
||||||
widget.info.src === this.info.src &&
|
|
||||||
widget.info.from === this.info.from &&
|
|
||||||
widget.info.to === this.info.to &&
|
|
||||||
widget.isLoaded === this.isLoaded
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
ignoreEvent(): boolean {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Image preview plugin class.
|
|
||||||
*/
|
|
||||||
class ImagePreviewPlugin {
|
|
||||||
decorations: DecorationSet;
|
|
||||||
private loadedImages: Set<string> = new Set();
|
|
||||||
private lastSelectionRanges: string = '';
|
|
||||||
|
|
||||||
constructor(view: EditorView) {
|
|
||||||
this.decorations = buildImageDecorations(view, this.loadedImages);
|
|
||||||
this.lastSelectionRanges = this.serializeSelection(view);
|
|
||||||
}
|
|
||||||
|
|
||||||
update(update: ViewUpdate) {
|
|
||||||
if (update.docChanged || update.viewportChanged) {
|
|
||||||
this.decorations = buildImageDecorations(update.view, this.loadedImages);
|
|
||||||
this.lastSelectionRanges = this.serializeSelection(update.view);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (update.selectionSet) {
|
|
||||||
const newRanges = this.serializeSelection(update.view);
|
|
||||||
if (newRanges !== this.lastSelectionRanges) {
|
|
||||||
this.decorations = buildImageDecorations(update.view, this.loadedImages);
|
|
||||||
this.lastSelectionRanges = newRanges;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!update.docChanged && !update.selectionSet && !update.viewportChanged) {
|
|
||||||
this.decorations = buildImageDecorations(update.view, this.loadedImages);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private serializeSelection(view: EditorView): string {
|
|
||||||
return view.state.selection.ranges
|
|
||||||
.map((r) => `${r.from}:${r.to}`)
|
|
||||||
.join(',');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Image preview extension.
|
|
||||||
* Only handles displaying image preview widget.
|
|
||||||
*/
|
|
||||||
export const imagePreview = (): Extension => [
|
|
||||||
ViewPlugin.fromClass(ImagePreviewPlugin, {
|
|
||||||
decorations: (v) => v.decorations
|
|
||||||
}),
|
|
||||||
baseTheme
|
|
||||||
];
|
|
||||||
|
|
||||||
const baseTheme = EditorView.baseTheme({
|
|
||||||
'.cm-image-preview-wrapper': {
|
|
||||||
display: 'block',
|
|
||||||
margin: '0.5rem 0'
|
|
||||||
},
|
|
||||||
[`.${classes.widget}`]: {
|
|
||||||
maxWidth: '100%',
|
|
||||||
height: 'auto',
|
|
||||||
borderRadius: '0.25rem'
|
|
||||||
},
|
|
||||||
'.cm-image-loading': {
|
|
||||||
display: 'inline-block',
|
|
||||||
color: 'var(--cm-foreground)',
|
|
||||||
opacity: '0.6'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
286
frontend/src/views/editor/extensions/markdown/syntax/footnote.ts
Normal file
286
frontend/src/views/editor/extensions/markdown/syntax/footnote.ts
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
/**
|
||||||
|
* Footnote extension for Lezer Markdown parser.
|
||||||
|
*
|
||||||
|
* Parses footnote syntax compatible with MultiMarkdown/PHP Markdown Extra.
|
||||||
|
*
|
||||||
|
* Syntax:
|
||||||
|
* - Footnote reference: [^id] or [^1]
|
||||||
|
* - Footnote definition: [^id]: content (at line start)
|
||||||
|
* - Inline footnote: ^[content] (content is inline, no separate definition needed)
|
||||||
|
*
|
||||||
|
* Examples:
|
||||||
|
* - This is text[^1] with a footnote.
|
||||||
|
* - [^1]: This is the footnote content.
|
||||||
|
* - This is text^[inline footnote content] with inline footnote.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { MarkdownConfig, Line, BlockContext } from '@lezer/markdown';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ASCII character codes for parsing.
|
||||||
|
*/
|
||||||
|
const enum Ch {
|
||||||
|
OpenBracket = 91, // [
|
||||||
|
CloseBracket = 93, // ]
|
||||||
|
Caret = 94, // ^
|
||||||
|
Colon = 58, // :
|
||||||
|
Space = 32,
|
||||||
|
Tab = 9,
|
||||||
|
Newline = 10,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a character is valid for footnote ID.
|
||||||
|
* Allows: letters, numbers, underscore, hyphen
|
||||||
|
*/
|
||||||
|
function isFootnoteIdChar(code: number): boolean {
|
||||||
|
return (
|
||||||
|
(code >= 48 && code <= 57) || // 0-9
|
||||||
|
(code >= 65 && code <= 90) || // A-Z
|
||||||
|
(code >= 97 && code <= 122) || // a-z
|
||||||
|
code === 95 || // _
|
||||||
|
code === 45 // -
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Footnote extension for Lezer Markdown.
|
||||||
|
*
|
||||||
|
* Defines nodes:
|
||||||
|
* - FootnoteReference: Inline reference [^id]
|
||||||
|
* - FootnoteReferenceMark: The [^ and ] delimiters
|
||||||
|
* - FootnoteReferenceLabel: The id part
|
||||||
|
* - FootnoteDefinition: Block definition [^id]: content
|
||||||
|
* - FootnoteDefinitionMark: The [^, ]: delimiters
|
||||||
|
* - FootnoteDefinitionLabel: The id part in definition
|
||||||
|
* - FootnoteDefinitionContent: The content part
|
||||||
|
* - InlineFootnote: Inline footnote ^[content]
|
||||||
|
* - InlineFootnoteMark: The ^[ and ] delimiters
|
||||||
|
* - InlineFootnoteContent: The content part
|
||||||
|
*/
|
||||||
|
export const Footnote: MarkdownConfig = {
|
||||||
|
defineNodes: [
|
||||||
|
// Inline reference nodes
|
||||||
|
{ name: 'FootnoteReference' },
|
||||||
|
{ name: 'FootnoteReferenceMark' },
|
||||||
|
{ name: 'FootnoteReferenceLabel' },
|
||||||
|
// Block definition nodes
|
||||||
|
{ name: 'FootnoteDefinition', block: true },
|
||||||
|
{ name: 'FootnoteDefinitionMark' },
|
||||||
|
{ name: 'FootnoteDefinitionLabel' },
|
||||||
|
{ name: 'FootnoteDefinitionContent' },
|
||||||
|
// Inline footnote nodes
|
||||||
|
{ name: 'InlineFootnote' },
|
||||||
|
{ name: 'InlineFootnoteMark' },
|
||||||
|
{ name: 'InlineFootnoteContent' },
|
||||||
|
],
|
||||||
|
|
||||||
|
parseInline: [
|
||||||
|
// Inline footnote must be parsed before Superscript to handle ^[ pattern
|
||||||
|
{
|
||||||
|
name: 'InlineFootnote',
|
||||||
|
parse(cx, next, pos) {
|
||||||
|
// Check for ^[ pattern
|
||||||
|
if (next !== Ch.Caret || cx.char(pos + 1) !== Ch.OpenBracket) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the closing ]
|
||||||
|
// Content can contain any characters except unbalanced brackets and newlines
|
||||||
|
let end = pos + 2;
|
||||||
|
let bracketDepth = 1; // We're inside one [
|
||||||
|
let hasContent = false;
|
||||||
|
|
||||||
|
while (end < cx.end) {
|
||||||
|
const char = cx.char(end);
|
||||||
|
|
||||||
|
// Don't allow newlines in inline footnotes
|
||||||
|
if (char === Ch.Newline) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track bracket depth for nested brackets
|
||||||
|
if (char === Ch.OpenBracket) {
|
||||||
|
bracketDepth++;
|
||||||
|
} else if (char === Ch.CloseBracket) {
|
||||||
|
bracketDepth--;
|
||||||
|
if (bracketDepth === 0) {
|
||||||
|
// Found the closing bracket
|
||||||
|
if (!hasContent) {
|
||||||
|
return -1; // Empty inline footnote
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the element with marks and content
|
||||||
|
const children = [
|
||||||
|
// Opening mark ^[
|
||||||
|
cx.elt('InlineFootnoteMark', pos, pos + 2),
|
||||||
|
// Content
|
||||||
|
cx.elt('InlineFootnoteContent', pos + 2, end),
|
||||||
|
// Closing mark ]
|
||||||
|
cx.elt('InlineFootnoteMark', end, end + 1),
|
||||||
|
];
|
||||||
|
|
||||||
|
const element = cx.elt('InlineFootnote', pos, end + 1, children);
|
||||||
|
return cx.addElement(element);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
hasContent = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
end++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return -1;
|
||||||
|
},
|
||||||
|
// Parse before Superscript to avoid ^[ being misinterpreted
|
||||||
|
before: 'Superscript',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'FootnoteReference',
|
||||||
|
parse(cx, next, pos) {
|
||||||
|
// Check for [^ pattern
|
||||||
|
if (next !== Ch.OpenBracket || cx.char(pos + 1) !== Ch.Caret) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the closing ]
|
||||||
|
let end = pos + 2;
|
||||||
|
let hasValidId = false;
|
||||||
|
|
||||||
|
while (end < cx.end) {
|
||||||
|
const char = cx.char(end);
|
||||||
|
|
||||||
|
// Found closing bracket
|
||||||
|
if (char === Ch.CloseBracket) {
|
||||||
|
if (!hasValidId) {
|
||||||
|
return -1; // Empty footnote reference
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the element with marks and label
|
||||||
|
const children = [
|
||||||
|
// Opening mark [^
|
||||||
|
cx.elt('FootnoteReferenceMark', pos, pos + 2),
|
||||||
|
// Label (the id)
|
||||||
|
cx.elt('FootnoteReferenceLabel', pos + 2, end),
|
||||||
|
// Closing mark ]
|
||||||
|
cx.elt('FootnoteReferenceMark', end, end + 1),
|
||||||
|
];
|
||||||
|
|
||||||
|
const element = cx.elt('FootnoteReference', pos, end + 1, children);
|
||||||
|
return cx.addElement(element);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't allow newlines
|
||||||
|
if (char === Ch.Newline) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate id characters
|
||||||
|
if (isFootnoteIdChar(char)) {
|
||||||
|
hasValidId = true;
|
||||||
|
} else {
|
||||||
|
// Invalid character in footnote id
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
end++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return -1;
|
||||||
|
},
|
||||||
|
// Parse before links to avoid conflicts
|
||||||
|
before: 'Link',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
parseBlock: [
|
||||||
|
{
|
||||||
|
name: 'FootnoteDefinition',
|
||||||
|
parse(cx: BlockContext, line: Line): boolean {
|
||||||
|
// Must start at the beginning of a line
|
||||||
|
// Check for [^ pattern
|
||||||
|
const text = line.text;
|
||||||
|
if (text.charCodeAt(0) !== Ch.OpenBracket ||
|
||||||
|
text.charCodeAt(1) !== Ch.Caret) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find ]: pattern
|
||||||
|
let labelEnd = 2;
|
||||||
|
while (labelEnd < text.length) {
|
||||||
|
const char = text.charCodeAt(labelEnd);
|
||||||
|
|
||||||
|
if (char === Ch.CloseBracket) {
|
||||||
|
// Check for : after ]
|
||||||
|
if (labelEnd + 1 < text.length &&
|
||||||
|
text.charCodeAt(labelEnd + 1) === Ch.Colon) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isFootnoteIdChar(char)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
labelEnd++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must have found ]:
|
||||||
|
if (labelEnd >= text.length ||
|
||||||
|
text.charCodeAt(labelEnd) !== Ch.CloseBracket ||
|
||||||
|
text.charCodeAt(labelEnd + 1) !== Ch.Colon) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate positions
|
||||||
|
const start = cx.lineStart;
|
||||||
|
const openMarkEnd = start + 2; // [^
|
||||||
|
const labelStart = openMarkEnd;
|
||||||
|
const labelEndPos = start + labelEnd;
|
||||||
|
const closeMarkStart = labelEndPos;
|
||||||
|
const closeMarkEnd = start + labelEnd + 2; // ]:
|
||||||
|
const contentStart = closeMarkEnd;
|
||||||
|
|
||||||
|
// Skip optional space after :
|
||||||
|
let contentOffset = labelEnd + 2;
|
||||||
|
if (contentOffset < text.length &&
|
||||||
|
(text.charCodeAt(contentOffset) === Ch.Space ||
|
||||||
|
text.charCodeAt(contentOffset) === Ch.Tab)) {
|
||||||
|
contentOffset++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the element
|
||||||
|
const children = [
|
||||||
|
// Opening mark [^
|
||||||
|
cx.elt('FootnoteDefinitionMark', start, openMarkEnd),
|
||||||
|
// Label
|
||||||
|
cx.elt('FootnoteDefinitionLabel', labelStart, labelEndPos),
|
||||||
|
// Closing mark ]:
|
||||||
|
cx.elt('FootnoteDefinitionMark', closeMarkStart, closeMarkEnd),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Add content if present
|
||||||
|
const contentText = text.slice(contentOffset);
|
||||||
|
if (contentText.length > 0) {
|
||||||
|
children.push(
|
||||||
|
cx.elt('FootnoteDefinitionContent', start + contentOffset, start + text.length)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the block element
|
||||||
|
const element = cx.elt('FootnoteDefinition', start, start + text.length, children);
|
||||||
|
cx.addElement(element);
|
||||||
|
|
||||||
|
// Move to next line
|
||||||
|
cx.nextLine();
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
// Parse before other block elements
|
||||||
|
before: 'LinkReference',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Footnote;
|
||||||
|
|
||||||
Reference in New Issue
Block a user