🚧 Added support for markdown preview math
This commit is contained in:
8
frontend/package-lock.json
generated
8
frontend/package-lock.json
generated
@@ -44,6 +44,7 @@
|
||||
"@replit/codemirror-lang-svelte": "^6.0.0",
|
||||
"@toml-tools/lexer": "^1.0.0",
|
||||
"@toml-tools/parser": "^1.0.0",
|
||||
"@types/katex": "^0.16.7",
|
||||
"codemirror": "^6.0.2",
|
||||
"codemirror-lang-elixir": "^4.0.0",
|
||||
"colors-named": "^1.0.2",
|
||||
@@ -51,6 +52,7 @@
|
||||
"groovy-beautify": "^0.0.17",
|
||||
"hsl-matcher": "^1.2.4",
|
||||
"java-parser": "^3.0.1",
|
||||
"katex": "^0.16.25",
|
||||
"linguist-languages": "^9.1.0",
|
||||
"marked": "^17.0.1",
|
||||
"mermaid": "^11.12.1",
|
||||
@@ -2831,6 +2833,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/katex": {
|
||||
"version": "0.16.7",
|
||||
"resolved": "https://registry.npmmirror.com/@types/katex/-/katex-0.16.7.tgz",
|
||||
"integrity": "sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/linkify-it": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/@types/linkify-it/-/linkify-it-5.0.0.tgz",
|
||||
|
||||
@@ -58,6 +58,7 @@
|
||||
"@replit/codemirror-lang-svelte": "^6.0.0",
|
||||
"@toml-tools/lexer": "^1.0.0",
|
||||
"@toml-tools/parser": "^1.0.0",
|
||||
"@types/katex": "^0.16.7",
|
||||
"codemirror": "^6.0.2",
|
||||
"codemirror-lang-elixir": "^4.0.0",
|
||||
"colors-named": "^1.0.2",
|
||||
@@ -65,6 +66,7 @@
|
||||
"groovy-beautify": "^0.0.17",
|
||||
"hsl-matcher": "^1.2.4",
|
||||
"java-parser": "^3.0.1",
|
||||
"katex": "^0.16.25",
|
||||
"linguist-languages": "^9.1.0",
|
||||
"marked": "^17.0.1",
|
||||
"mermaid": "^11.12.1",
|
||||
|
||||
@@ -10,6 +10,8 @@ import {StandardSQL} from "@codemirror/lang-sql";
|
||||
import {markdown, markdownLanguage} from "@codemirror/lang-markdown";
|
||||
import {Subscript, Superscript, Table} from "@lezer/markdown";
|
||||
import {Highlight} from "@/views/editor/extensions/markdown/syntax/highlight";
|
||||
import {Insert} from "@/views/editor/extensions/markdown/syntax/insert";
|
||||
import {Math} from "@/views/editor/extensions/markdown/syntax/math";
|
||||
import {Footnote} from "@/views/editor/extensions/markdown/syntax/footnote";
|
||||
import {javaLanguage} from "@codemirror/lang-java";
|
||||
import {phpLanguage} from "@codemirror/lang-php";
|
||||
@@ -116,7 +118,7 @@ export const LANGUAGES: LanguageInfo[] = [
|
||||
}),
|
||||
new LanguageInfo("md", "Markdown", markdown({
|
||||
base: markdownLanguage,
|
||||
extensions: [Subscript, Superscript, Highlight, Footnote, Table],
|
||||
extensions: [Subscript, Superscript, Highlight, Insert, Math, Footnote, Table],
|
||||
completeHTMLTags: true,
|
||||
pasteURLAsLink: true,
|
||||
htmlTagLanguage: html({
|
||||
|
||||
@@ -12,6 +12,8 @@ import { horizontalRule } from './plugins/horizontal-rule';
|
||||
import { inlineCode } from './plugins/inline-code';
|
||||
import { subscriptSuperscript } from './plugins/subscript-superscript';
|
||||
import { highlight } from './plugins/highlight';
|
||||
import { insert } from './plugins/insert';
|
||||
import { math } from './plugins/math';
|
||||
import { footnote } from './plugins/footnote';
|
||||
|
||||
/**
|
||||
@@ -31,6 +33,8 @@ export const markdownExtensions: Extension = [
|
||||
inlineCode(),
|
||||
subscriptSuperscript(),
|
||||
highlight(),
|
||||
insert(),
|
||||
math(),
|
||||
footnote(),
|
||||
];
|
||||
|
||||
|
||||
@@ -216,7 +216,7 @@ class FootnoteRefWidget extends WidgetType {
|
||||
toDOM(): HTMLElement {
|
||||
const span = document.createElement('span');
|
||||
span.className = 'cm-footnote-ref';
|
||||
span.textContent = String(this.index);
|
||||
span.textContent = `[${this.index}]`;
|
||||
span.dataset.footnoteId = this.id;
|
||||
|
||||
if (!this.hasDefinition) {
|
||||
@@ -249,7 +249,7 @@ class InlineFootnoteWidget extends WidgetType {
|
||||
toDOM(): HTMLElement {
|
||||
const span = document.createElement('span');
|
||||
span.className = 'cm-inline-footnote-ref';
|
||||
span.textContent = String(this.index);
|
||||
span.textContent = `[${this.index}]`;
|
||||
span.dataset.footnoteContent = this.content;
|
||||
span.dataset.footnoteIndex = String(this.index);
|
||||
|
||||
@@ -265,6 +265,31 @@ class InlineFootnoteWidget extends WidgetType {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Widget to display footnote definition label.
|
||||
*/
|
||||
class FootnoteDefLabelWidget extends WidgetType {
|
||||
constructor(readonly id: string) {
|
||||
super();
|
||||
}
|
||||
|
||||
toDOM(): HTMLElement {
|
||||
const span = document.createElement('span');
|
||||
span.className = 'cm-footnote-def-label';
|
||||
span.textContent = `[${this.id}]`;
|
||||
span.dataset.footnoteId = this.id;
|
||||
return span;
|
||||
}
|
||||
|
||||
eq(other: FootnoteDefLabelWidget): boolean {
|
||||
return this.id === other.id;
|
||||
}
|
||||
|
||||
ignoreEvent(): boolean {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Decorations
|
||||
// ============================================================================
|
||||
@@ -318,15 +343,18 @@ function buildDecorations(view: EditorView): DecorationSet {
|
||||
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));
|
||||
const id = view.state.sliceDoc(labelNode.from, labelNode.to);
|
||||
|
||||
// Style the label as definition marker
|
||||
// Hide the entire [^id]: part
|
||||
decorations.push(invisibleDecoration.range(marks[0].from, marks[1].to));
|
||||
|
||||
// Add widget to show [id]
|
||||
const widget = new FootnoteDefLabelWidget(id);
|
||||
decorations.push(
|
||||
Decoration.mark({
|
||||
class: 'cm-footnote-def-label',
|
||||
}).range(labelNode.from, labelNode.to)
|
||||
Decoration.widget({
|
||||
widget,
|
||||
side: 1,
|
||||
}).range(marks[1].to)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import { parseMixed } from '@lezer/common';
|
||||
import { yaml } from '@codemirror/legacy-modes/mode/yaml';
|
||||
import { Element, MarkdownExtension } from '@lezer/markdown';
|
||||
import { foldInside, foldNodeProp, StreamLanguage } from '@codemirror/language';
|
||||
import { styleTags, tags } from '@lezer/highlight';
|
||||
|
||||
// A frontmatter fence usually consists of a seperator used three times.
|
||||
// These can be: ---, +++, ===, etc.
|
||||
// FIXME: make this configurable
|
||||
const frontMatterFence = /^---\s*$/m;
|
||||
|
||||
/**
|
||||
* Ixora frontmatter plugin for Markdown.
|
||||
*/
|
||||
export const frontmatter: MarkdownExtension = {
|
||||
defineNodes: [{ name: 'Frontmatter', block: true }, 'FrontmatterMark'],
|
||||
props: [
|
||||
styleTags({
|
||||
Frontmatter: [tags.documentMeta, tags.monospace],
|
||||
FrontmatterMark: tags.processingInstruction
|
||||
}),
|
||||
foldNodeProp.add({
|
||||
Frontmatter: foldInside,
|
||||
// Marks don't need to be folded
|
||||
FrontmatterMark: () => null
|
||||
})
|
||||
],
|
||||
wrap: parseMixed((node) => {
|
||||
const { parser } = StreamLanguage.define(yaml);
|
||||
if (node.type.name === 'Frontmatter') {
|
||||
return {
|
||||
parser,
|
||||
overlay: [{ from: node.from + 4, to: node.to - 4 }]
|
||||
};
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
parseBlock: [
|
||||
{
|
||||
name: 'Fronmatter',
|
||||
before: 'HorizontalRule',
|
||||
parse: (cx, line) => {
|
||||
let end: number = 0;
|
||||
const children = new Array<Element>();
|
||||
if (cx.lineStart === 0 && frontMatterFence.test(line.text)) {
|
||||
// 4 is the length of the frontmatter fence (---\n).
|
||||
children.push(cx.elt('FrontmatterMark', 0, 4));
|
||||
while (cx.nextLine()) {
|
||||
if (frontMatterFence.test(line.text)) {
|
||||
end = cx.lineStart + 4;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (end > 0) {
|
||||
children.push(cx.elt('FrontmatterMark', end - 4, end));
|
||||
cx.addElement(cx.elt('Frontmatter', 0, end, children));
|
||||
}
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
@@ -116,19 +116,27 @@ const imageHoverTooltip = hoverTooltip(
|
||||
arrow: true,
|
||||
create: () => {
|
||||
const dom = document.createElement('div');
|
||||
dom.className = 'cm-image-tooltip';
|
||||
dom.className = 'cm-image-tooltip cm-image-loading';
|
||||
|
||||
const spinner = document.createElement('span');
|
||||
spinner.className = 'cm-image-spinner';
|
||||
|
||||
const imgEl = document.createElement('img');
|
||||
imgEl.src = img.src;
|
||||
imgEl.alt = img.alt;
|
||||
|
||||
imgEl.onload = () => {
|
||||
dom.classList.remove('cm-image-loading');
|
||||
};
|
||||
imgEl.onerror = () => {
|
||||
spinner.remove();
|
||||
imgEl.remove();
|
||||
dom.textContent = 'Failed to load image';
|
||||
dom.classList.remove('cm-image-loading');
|
||||
dom.classList.add('cm-image-tooltip-error');
|
||||
};
|
||||
|
||||
dom.append(imgEl);
|
||||
dom.append(spinner, imgEl);
|
||||
return { dom };
|
||||
}
|
||||
};
|
||||
@@ -150,15 +158,50 @@ const theme = EditorView.baseTheme({
|
||||
},
|
||||
'.cm-image-indicator:hover': { opacity: '1' },
|
||||
'.cm-image-tooltip': {
|
||||
background: 'var(--bg-secondary)',
|
||||
position: 'relative',
|
||||
background: `
|
||||
linear-gradient(45deg, #e0e0e0 25%, transparent 25%),
|
||||
linear-gradient(-45deg, #e0e0e0 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, #e0e0e0 75%),
|
||||
linear-gradient(-45deg, transparent 75%, #e0e0e0 75%)
|
||||
`,
|
||||
backgroundColor: '#fff',
|
||||
backgroundSize: '12px 12px',
|
||||
backgroundPosition: '0 0, 0 6px, 6px -6px, -6px 0px',
|
||||
border: '1px solid var(--border-color)',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
|
||||
'& img': {
|
||||
display: 'block',
|
||||
maxWidth: '60vw',
|
||||
maxHeight: '50vh'
|
||||
maxHeight: '50vh',
|
||||
opacity: '1',
|
||||
transition: 'opacity 0.15s ease-out'
|
||||
}
|
||||
},
|
||||
'.cm-image-loading': {
|
||||
minWidth: '48px',
|
||||
minHeight: '48px',
|
||||
'& img': { opacity: '0' }
|
||||
},
|
||||
'.cm-image-spinner': {
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
marginTop: '-8px',
|
||||
marginLeft: '-8px',
|
||||
border: '2px solid #ccc',
|
||||
borderTopColor: '#666',
|
||||
borderRadius: '50%',
|
||||
animation: 'cm-spin 0.5s linear infinite'
|
||||
},
|
||||
'.cm-image-tooltip:not(.cm-image-loading) .cm-image-spinner': {
|
||||
display: 'none'
|
||||
},
|
||||
'@keyframes cm-spin': {
|
||||
to: { transform: 'rotate(360deg)' }
|
||||
},
|
||||
'.cm-image-tooltip-error': {
|
||||
padding: '16px 24px',
|
||||
fontSize: '12px',
|
||||
@@ -169,8 +212,8 @@ const theme = EditorView.baseTheme({
|
||||
borderBottomColor: 'var(--border-color) !important'
|
||||
},
|
||||
'.cm-tooltip-arrow:after': {
|
||||
borderTopColor: 'var(--bg-secondary) !important',
|
||||
borderBottomColor: 'var(--bg-secondary) !important'
|
||||
borderTopColor: '#fff !important',
|
||||
borderBottomColor: '#fff !important'
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
114
frontend/src/views/editor/extensions/markdown/plugins/insert.ts
Normal file
114
frontend/src/views/editor/extensions/markdown/plugins/insert.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { Extension, Range } from '@codemirror/state';
|
||||
import { syntaxTree } from '@codemirror/language';
|
||||
import {
|
||||
ViewPlugin,
|
||||
DecorationSet,
|
||||
Decoration,
|
||||
EditorView,
|
||||
ViewUpdate
|
||||
} from '@codemirror/view';
|
||||
import { isCursorInRange, invisibleDecoration } from '../util';
|
||||
|
||||
/**
|
||||
* Insert plugin using syntax tree.
|
||||
*
|
||||
* Uses the custom Insert extension to detect:
|
||||
* - Insert: ++text++ → renders as inserted text (underline)
|
||||
*
|
||||
* Examples:
|
||||
* - This is ++inserted++ text → This is <ins>inserted</ins> text
|
||||
* - Please ++review this section++ carefully
|
||||
*/
|
||||
export const insert = (): Extension => [
|
||||
insertPlugin,
|
||||
baseTheme
|
||||
];
|
||||
|
||||
/**
|
||||
* Build decorations for insert using syntax tree.
|
||||
*/
|
||||
function buildDecorations(view: EditorView): DecorationSet {
|
||||
const decorations: Range<Decoration>[] = [];
|
||||
|
||||
for (const { from, to } of view.visibleRanges) {
|
||||
syntaxTree(view.state).iterate({
|
||||
from,
|
||||
to,
|
||||
enter: ({ type, from: nodeFrom, to: nodeTo, node }) => {
|
||||
// Handle Insert nodes
|
||||
if (type.name === 'Insert') {
|
||||
const cursorInRange = isCursorInRange(view.state, [nodeFrom, nodeTo]);
|
||||
|
||||
// Get the mark nodes (the ++ characters)
|
||||
const marks = node.getChildren('InsertMark');
|
||||
|
||||
if (!cursorInRange && marks.length >= 2) {
|
||||
// Hide the opening and closing ++ marks
|
||||
decorations.push(invisibleDecoration.range(marks[0].from, marks[0].to));
|
||||
decorations.push(invisibleDecoration.range(marks[marks.length - 1].from, marks[marks.length - 1].to));
|
||||
|
||||
// Apply insert style to the content between marks
|
||||
const contentStart = marks[0].to;
|
||||
const contentEnd = marks[marks.length - 1].from;
|
||||
if (contentStart < contentEnd) {
|
||||
decorations.push(
|
||||
Decoration.mark({
|
||||
class: 'cm-insert'
|
||||
}).range(contentStart, contentEnd)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return Decoration.set(decorations, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin class with optimized update detection.
|
||||
*/
|
||||
class InsertPlugin {
|
||||
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 insertPlugin = ViewPlugin.fromClass(
|
||||
InsertPlugin,
|
||||
{
|
||||
decorations: (v) => v.decorations
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Base theme for insert.
|
||||
* Uses underline decoration for inserted text.
|
||||
*/
|
||||
const baseTheme = EditorView.baseTheme({
|
||||
'.cm-insert': {
|
||||
textDecoration: 'underline',
|
||||
}
|
||||
});
|
||||
|
||||
358
frontend/src/views/editor/extensions/markdown/plugins/math.ts
Normal file
358
frontend/src/views/editor/extensions/markdown/plugins/math.ts
Normal file
@@ -0,0 +1,358 @@
|
||||
/**
|
||||
* Math plugin for CodeMirror using KaTeX.
|
||||
*
|
||||
* Features:
|
||||
* - Renders inline math $...$ as inline formula
|
||||
* - Renders block math $$...$$ as block formula
|
||||
* - Block math: lines remain, content hidden, formula overlays on top
|
||||
* - Shows source when cursor is inside
|
||||
*/
|
||||
|
||||
import { Extension, Range } from '@codemirror/state';
|
||||
import { syntaxTree } from '@codemirror/language';
|
||||
import {
|
||||
ViewPlugin,
|
||||
DecorationSet,
|
||||
Decoration,
|
||||
EditorView,
|
||||
ViewUpdate,
|
||||
WidgetType
|
||||
} from '@codemirror/view';
|
||||
import katex from 'katex';
|
||||
import 'katex/dist/katex.min.css';
|
||||
import { isCursorInRange, invisibleDecoration } from '../util';
|
||||
|
||||
// ============================================================================
|
||||
// Inline Math Widget
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Widget to display inline math formula.
|
||||
*/
|
||||
class InlineMathWidget extends WidgetType {
|
||||
private html: string;
|
||||
private error: string | null = null;
|
||||
|
||||
constructor(readonly latex: string) {
|
||||
super();
|
||||
try {
|
||||
this.html = katex.renderToString(latex, {
|
||||
throwOnError: true,
|
||||
displayMode: false,
|
||||
output: 'html'
|
||||
});
|
||||
} catch (e) {
|
||||
this.error = e instanceof Error ? e.message : 'Render error';
|
||||
this.html = '';
|
||||
}
|
||||
}
|
||||
|
||||
toDOM(): HTMLElement {
|
||||
const span = document.createElement('span');
|
||||
span.className = 'cm-inline-math';
|
||||
|
||||
if (this.error) {
|
||||
span.textContent = this.latex;
|
||||
span.title = this.error;
|
||||
} else {
|
||||
span.innerHTML = this.html;
|
||||
}
|
||||
|
||||
return span;
|
||||
}
|
||||
|
||||
eq(other: InlineMathWidget): boolean {
|
||||
return this.latex === other.latex;
|
||||
}
|
||||
|
||||
ignoreEvent(): boolean {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Block Math Widget
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Widget to display block math formula.
|
||||
* Uses absolute positioning to overlay on source lines.
|
||||
*/
|
||||
class BlockMathWidget extends WidgetType {
|
||||
private html: string;
|
||||
private error: string | null = null;
|
||||
|
||||
constructor(
|
||||
readonly latex: string,
|
||||
readonly lineCount: number = 1,
|
||||
readonly lineHeight: number = 22
|
||||
) {
|
||||
super();
|
||||
try {
|
||||
this.html = katex.renderToString(latex, {
|
||||
throwOnError: false,
|
||||
displayMode: true,
|
||||
output: 'html'
|
||||
});
|
||||
} catch (e) {
|
||||
this.error = e instanceof Error ? e.message : 'Render error';
|
||||
this.html = '';
|
||||
}
|
||||
}
|
||||
|
||||
toDOM(): HTMLElement {
|
||||
const container = document.createElement('div');
|
||||
container.className = 'cm-block-math-container';
|
||||
// Set height to cover all source lines
|
||||
const height = this.lineCount * this.lineHeight;
|
||||
container.style.height = `${height}px`;
|
||||
|
||||
const inner = document.createElement('div');
|
||||
inner.className = 'cm-block-math';
|
||||
|
||||
if (this.error) {
|
||||
inner.textContent = this.latex;
|
||||
inner.title = this.error;
|
||||
} else {
|
||||
inner.innerHTML = this.html;
|
||||
}
|
||||
|
||||
container.appendChild(inner);
|
||||
return container;
|
||||
}
|
||||
|
||||
eq(other: BlockMathWidget): boolean {
|
||||
return this.latex === other.latex && this.lineCount === other.lineCount;
|
||||
}
|
||||
|
||||
ignoreEvent(): boolean {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Decorations
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Build decorations for math formulas.
|
||||
*/
|
||||
function buildDecorations(view: EditorView): DecorationSet {
|
||||
const decorations: Range<Decoration>[] = [];
|
||||
|
||||
for (const { from, to } of view.visibleRanges) {
|
||||
syntaxTree(view.state).iterate({
|
||||
from,
|
||||
to,
|
||||
enter: ({ type, from: nodeFrom, to: nodeTo, node }) => {
|
||||
// Handle inline math
|
||||
if (type.name === 'InlineMath') {
|
||||
const cursorInRange = isCursorInRange(view.state, [nodeFrom, nodeTo]);
|
||||
const marks = node.getChildren('InlineMathMark');
|
||||
|
||||
if (!cursorInRange && marks.length >= 2) {
|
||||
// Get latex content (without $ marks)
|
||||
const latex = view.state.sliceDoc(marks[0].to, marks[marks.length - 1].from);
|
||||
|
||||
// Hide the entire syntax
|
||||
decorations.push(invisibleDecoration.range(nodeFrom, nodeTo));
|
||||
|
||||
// Add widget at the end
|
||||
decorations.push(
|
||||
Decoration.widget({
|
||||
widget: new InlineMathWidget(latex),
|
||||
side: 1
|
||||
}).range(nodeTo)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle block math ($$...$$)
|
||||
if (type.name === 'BlockMath') {
|
||||
const cursorInRange = isCursorInRange(view.state, [nodeFrom, nodeTo]);
|
||||
const marks = node.getChildren('BlockMathMark');
|
||||
|
||||
if (!cursorInRange && marks.length >= 2) {
|
||||
// Get latex content (without $$ marks)
|
||||
const latex = view.state.sliceDoc(marks[0].to, marks[marks.length - 1].from).trim();
|
||||
|
||||
// Calculate line info
|
||||
const startLine = view.state.doc.lineAt(nodeFrom);
|
||||
const endLine = view.state.doc.lineAt(nodeTo);
|
||||
const lineCount = endLine.number - startLine.number + 1;
|
||||
const lineHeight = view.defaultLineHeight;
|
||||
|
||||
// Check if block math spans multiple lines
|
||||
const hasLineBreak = lineCount > 1;
|
||||
|
||||
if (hasLineBreak) {
|
||||
// For multi-line: use line decorations to hide content
|
||||
for (let lineNum = startLine.number; lineNum <= endLine.number; lineNum++) {
|
||||
const line = view.state.doc.line(lineNum);
|
||||
decorations.push(
|
||||
Decoration.line({
|
||||
class: 'cm-block-math-line'
|
||||
}).range(line.from)
|
||||
);
|
||||
}
|
||||
|
||||
// Add widget on the first line (positioned absolutely)
|
||||
decorations.push(
|
||||
Decoration.widget({
|
||||
widget: new BlockMathWidget(latex, lineCount, lineHeight),
|
||||
side: -1
|
||||
}).range(startLine.from)
|
||||
);
|
||||
} else {
|
||||
// Single line: make content transparent, overlay widget
|
||||
decorations.push(
|
||||
Decoration.mark({
|
||||
class: 'cm-block-math-content-hidden'
|
||||
}).range(nodeFrom, nodeTo)
|
||||
);
|
||||
|
||||
// Add widget at the start (positioned absolutely)
|
||||
decorations.push(
|
||||
Decoration.widget({
|
||||
widget: new BlockMathWidget(latex, 1, lineHeight),
|
||||
side: -1
|
||||
}).range(nodeFrom)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return Decoration.set(decorations, true);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Plugin
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Math plugin with optimized update detection.
|
||||
*/
|
||||
class MathPlugin {
|
||||
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 mathPlugin = ViewPlugin.fromClass(
|
||||
MathPlugin,
|
||||
{
|
||||
decorations: (v) => v.decorations
|
||||
}
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// Theme
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Base theme for math.
|
||||
*/
|
||||
const baseTheme = EditorView.baseTheme({
|
||||
// Inline math
|
||||
'.cm-inline-math': {
|
||||
display: 'inline',
|
||||
verticalAlign: 'baseline',
|
||||
},
|
||||
'.cm-inline-math .katex': {
|
||||
fontSize: 'inherit',
|
||||
},
|
||||
|
||||
// Block math container - absolute positioned to overlay on source
|
||||
'.cm-block-math-container': {
|
||||
position: 'absolute',
|
||||
left: '0',
|
||||
right: '0',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
pointerEvents: 'none',
|
||||
zIndex: '1',
|
||||
},
|
||||
|
||||
// Block math inner
|
||||
'.cm-block-math': {
|
||||
display: 'inline-block',
|
||||
textAlign: 'center',
|
||||
pointerEvents: 'auto',
|
||||
},
|
||||
'.cm-block-math .katex-display': {
|
||||
margin: '0',
|
||||
},
|
||||
'.cm-block-math .katex': {
|
||||
fontSize: '1.1em',
|
||||
},
|
||||
|
||||
// Hidden line content for block math (text transparent but line preserved)
|
||||
// Use high specificity to override rainbow brackets and other plugins
|
||||
'.cm-line.cm-block-math-line': {
|
||||
color: 'transparent !important',
|
||||
caretColor: 'transparent',
|
||||
},
|
||||
'.cm-line.cm-block-math-line span': {
|
||||
color: 'transparent !important',
|
||||
},
|
||||
// Override rainbow brackets in hidden math lines
|
||||
'.cm-line.cm-block-math-line [class*="cm-rainbow-bracket"]': {
|
||||
color: 'transparent !important',
|
||||
},
|
||||
|
||||
// Hidden content for single-line block math
|
||||
'.cm-block-math-content-hidden': {
|
||||
color: 'transparent !important',
|
||||
},
|
||||
'.cm-block-math-content-hidden span': {
|
||||
color: 'transparent !important',
|
||||
},
|
||||
'.cm-block-math-content-hidden [class*="cm-rainbow-bracket"]': {
|
||||
color: 'transparent !important',
|
||||
},
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Export
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Math extension.
|
||||
*
|
||||
* Features:
|
||||
* - Parses inline math $...$ and block math $$...$$
|
||||
* - Renders formulas using KaTeX
|
||||
* - Block math preserves line structure, overlays rendered formula
|
||||
* - Shows source when cursor is inside
|
||||
*/
|
||||
export const math = (): Extension => [
|
||||
mathPlugin,
|
||||
baseTheme
|
||||
];
|
||||
|
||||
export default math;
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import { syntaxTree } from '@codemirror/language';
|
||||
import { EditorState } from '@codemirror/state';
|
||||
|
||||
/**
|
||||
* Basic table information extracted from syntax tree.
|
||||
*/
|
||||
export interface TableInfo {
|
||||
/** Starting position in document */
|
||||
from: number;
|
||||
/** End position in document */
|
||||
to: number;
|
||||
/** Raw markdown text */
|
||||
rawText: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all tables from the editor state.
|
||||
*/
|
||||
export function extractTablesFromState(state: EditorState): TableInfo[] {
|
||||
const tables: TableInfo[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
syntaxTree(state).iterate({
|
||||
enter: ({ type, from, to }) => {
|
||||
if (type.name !== 'Table') return;
|
||||
|
||||
// Deduplicate
|
||||
const key = `${from}:${to}`;
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
|
||||
const rawText = state.doc.sliceString(from, to);
|
||||
|
||||
// Need at least 2 lines (header + delimiter)
|
||||
if (rawText.split('\n').length < 2) return;
|
||||
|
||||
tables.push({ from, to, rawText });
|
||||
}
|
||||
});
|
||||
|
||||
return tables;
|
||||
}
|
||||
@@ -14,33 +14,173 @@
|
||||
* - This is text^[inline footnote content] with inline footnote.
|
||||
*/
|
||||
|
||||
import { MarkdownConfig, Line, BlockContext } from '@lezer/markdown';
|
||||
import { MarkdownConfig, Line, BlockContext, InlineContext } from '@lezer/markdown';
|
||||
import { CharCode, isFootnoteIdChar } from '../util';
|
||||
|
||||
/**
|
||||
* ASCII character codes for parsing.
|
||||
* Parse inline footnote ^[content].
|
||||
*
|
||||
* @param cx - Inline context
|
||||
* @param pos - Start position (at ^)
|
||||
* @returns Position after element, or -1 if no match
|
||||
*/
|
||||
const enum Ch {
|
||||
OpenBracket = 91, // [
|
||||
CloseBracket = 93, // ]
|
||||
Caret = 94, // ^
|
||||
Colon = 58, // :
|
||||
Space = 32,
|
||||
Tab = 9,
|
||||
Newline = 10,
|
||||
function parseInlineFootnote(cx: InlineContext, pos: number): number {
|
||||
const end = cx.end;
|
||||
|
||||
// Minimum: ^[ + content + ] = at least 4 chars
|
||||
if (end < pos + 3) return -1;
|
||||
|
||||
// Track bracket depth for nested brackets
|
||||
let bracketDepth = 1;
|
||||
let hasContent = false;
|
||||
const contentStart = pos + 2;
|
||||
|
||||
for (let i = contentStart; i < end; i++) {
|
||||
const char = cx.char(i);
|
||||
|
||||
// Don't allow newlines
|
||||
if (char === CharCode.Newline) return -1;
|
||||
|
||||
// Track bracket depth
|
||||
if (char === CharCode.OpenBracket) {
|
||||
bracketDepth++;
|
||||
} else if (char === CharCode.CloseBracket) {
|
||||
bracketDepth--;
|
||||
if (bracketDepth === 0) {
|
||||
// Found closing bracket - must have content
|
||||
if (!hasContent) return -1;
|
||||
|
||||
// Create element with marks and content
|
||||
return cx.addElement(cx.elt('InlineFootnote', pos, i + 1, [
|
||||
cx.elt('InlineFootnoteMark', pos, contentStart),
|
||||
cx.elt('InlineFootnoteContent', contentStart, i),
|
||||
cx.elt('InlineFootnoteMark', i, i + 1)
|
||||
]));
|
||||
}
|
||||
} else {
|
||||
hasContent = true;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a character is valid for footnote ID.
|
||||
* Allows: letters, numbers, underscore, hyphen
|
||||
* Parse footnote reference [^id].
|
||||
*
|
||||
* @param cx - Inline context
|
||||
* @param pos - Start position (at [)
|
||||
* @returns Position after element, or -1 if no match
|
||||
*/
|
||||
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 // -
|
||||
);
|
||||
function parseFootnoteReference(cx: InlineContext, pos: number): number {
|
||||
const end = cx.end;
|
||||
|
||||
// Minimum: [^ + id + ] = at least 4 chars
|
||||
if (end < pos + 3) return -1;
|
||||
|
||||
let hasValidId = false;
|
||||
const labelStart = pos + 2;
|
||||
|
||||
for (let i = labelStart; i < end; i++) {
|
||||
const char = cx.char(i);
|
||||
|
||||
// Found closing bracket
|
||||
if (char === CharCode.CloseBracket) {
|
||||
if (!hasValidId) return -1;
|
||||
|
||||
// Create element with marks and label
|
||||
return cx.addElement(cx.elt('FootnoteReference', pos, i + 1, [
|
||||
cx.elt('FootnoteReferenceMark', pos, labelStart),
|
||||
cx.elt('FootnoteReferenceLabel', labelStart, i),
|
||||
cx.elt('FootnoteReferenceMark', i, i + 1)
|
||||
]));
|
||||
}
|
||||
|
||||
// Don't allow newlines
|
||||
if (char === CharCode.Newline) return -1;
|
||||
|
||||
// Validate id character using O(1) lookup table
|
||||
if (isFootnoteIdChar(char)) {
|
||||
hasValidId = true;
|
||||
} else {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse footnote definition [^id]: content.
|
||||
*
|
||||
* @param cx - Block context
|
||||
* @param line - Current line
|
||||
* @returns True if parsed successfully
|
||||
*/
|
||||
function parseFootnoteDefinition(cx: BlockContext, line: Line): boolean {
|
||||
const text = line.text;
|
||||
const len = text.length;
|
||||
|
||||
// Minimum: [^id]: = at least 5 chars
|
||||
if (len < 5) return false;
|
||||
|
||||
// Find ]: pattern - use O(1) lookup for ID chars
|
||||
let labelEnd = 2;
|
||||
while (labelEnd < len) {
|
||||
const char = text.charCodeAt(labelEnd);
|
||||
|
||||
if (char === CharCode.CloseBracket) {
|
||||
// Check for : after ]
|
||||
if (labelEnd + 1 < len && text.charCodeAt(labelEnd + 1) === CharCode.Colon) {
|
||||
break;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Use O(1) lookup table
|
||||
if (!isFootnoteIdChar(char)) return false;
|
||||
|
||||
labelEnd++;
|
||||
}
|
||||
|
||||
// Validate ]: was found
|
||||
if (labelEnd >= len ||
|
||||
text.charCodeAt(labelEnd) !== CharCode.CloseBracket ||
|
||||
text.charCodeAt(labelEnd + 1) !== CharCode.Colon) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Calculate positions (all at once to avoid repeated arithmetic)
|
||||
const start = cx.lineStart;
|
||||
const openMarkEnd = start + 2;
|
||||
const labelEndPos = start + labelEnd;
|
||||
const closeMarkEnd = start + labelEnd + 2;
|
||||
|
||||
// Skip optional space after :
|
||||
let contentOffset = labelEnd + 2;
|
||||
if (contentOffset < len) {
|
||||
const spaceChar = text.charCodeAt(contentOffset);
|
||||
if (spaceChar === CharCode.Space || spaceChar === CharCode.Tab) {
|
||||
contentOffset++;
|
||||
}
|
||||
}
|
||||
|
||||
// Build children array
|
||||
const children = [
|
||||
cx.elt('FootnoteDefinitionMark', start, openMarkEnd),
|
||||
cx.elt('FootnoteDefinitionLabel', openMarkEnd, labelEndPos),
|
||||
cx.elt('FootnoteDefinitionMark', labelEndPos, closeMarkEnd)
|
||||
];
|
||||
|
||||
// Add content if present
|
||||
if (contentOffset < len) {
|
||||
children.push(cx.elt('FootnoteDefinitionContent', start + contentOffset, start + len));
|
||||
}
|
||||
|
||||
// Create and add block element
|
||||
cx.addElement(cx.elt('FootnoteDefinition', start, start + len, children));
|
||||
cx.nextLine();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -76,119 +216,26 @@ export const Footnote: MarkdownConfig = {
|
||||
],
|
||||
|
||||
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) {
|
||||
// Fast path: must start with ^[
|
||||
if (next !== CharCode.Caret || cx.char(pos + 1) !== CharCode.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;
|
||||
return parseInlineFootnote(cx, pos);
|
||||
},
|
||||
// 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) {
|
||||
// Fast path: must start with [^
|
||||
if (next !== CharCode.OpenBracket || cx.char(pos + 1) !== CharCode.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;
|
||||
return parseFootnoteReference(cx, pos);
|
||||
},
|
||||
// Parse before links to avoid conflicts
|
||||
before: 'Link',
|
||||
},
|
||||
],
|
||||
@@ -197,90 +244,16 @@ export const Footnote: MarkdownConfig = {
|
||||
{
|
||||
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) {
|
||||
// Fast path: must start with [^
|
||||
if (line.text.charCodeAt(0) !== CharCode.OpenBracket ||
|
||||
line.text.charCodeAt(1) !== CharCode.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;
|
||||
return parseFootnoteDefinition(cx, line);
|
||||
},
|
||||
// Parse before other block elements
|
||||
before: 'LinkReference',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default Footnote;
|
||||
|
||||
|
||||
@@ -10,10 +10,10 @@
|
||||
*/
|
||||
|
||||
import { MarkdownConfig } from '@lezer/markdown';
|
||||
import { CharCode, createPairedDelimiterParser } from '../util';
|
||||
|
||||
/**
|
||||
* Highlight extension for Lezer Markdown.
|
||||
*
|
||||
* Defines:
|
||||
* - Highlight: The container node for highlighted content
|
||||
* - HighlightMark: The == delimiter marks
|
||||
@@ -23,50 +23,16 @@ export const Highlight: MarkdownConfig = {
|
||||
{ name: 'Highlight' },
|
||||
{ name: 'HighlightMark' }
|
||||
],
|
||||
parseInline: [{
|
||||
parseInline: [
|
||||
createPairedDelimiterParser({
|
||||
name: 'Highlight',
|
||||
parse(cx, next, pos) {
|
||||
// Check for == delimiter (= is ASCII 61)
|
||||
if (next !== 61 || cx.char(pos + 1) !== 61) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Don't match === or more (horizontal rule or other constructs)
|
||||
if (cx.char(pos + 2) === 61) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Look for closing == delimiter
|
||||
for (let i = pos + 2; i < cx.end - 1; i++) {
|
||||
const char = cx.char(i);
|
||||
|
||||
// Don't allow newlines within highlight
|
||||
if (char === 10) { // newline
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Found potential closing ==
|
||||
if (char === 61 && cx.char(i + 1) === 61) {
|
||||
// Make sure it's not ===
|
||||
if (i + 2 < cx.end && cx.char(i + 2) === 61) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Create the element with marks
|
||||
const element = cx.elt('Highlight', pos, i + 2, [
|
||||
cx.elt('HighlightMark', pos, pos + 2),
|
||||
cx.elt('HighlightMark', i, i + 2)
|
||||
]);
|
||||
return cx.addElement(element);
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
},
|
||||
// Parse after emphasis to avoid conflicts with other inline parsers
|
||||
nodeName: 'Highlight',
|
||||
markName: 'HighlightMark',
|
||||
delimChar: CharCode.Equal,
|
||||
isDouble: true,
|
||||
after: 'Emphasis'
|
||||
}]
|
||||
})
|
||||
]
|
||||
};
|
||||
|
||||
export default Highlight;
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Insert extension for Lezer Markdown parser.
|
||||
*
|
||||
* Parses ++insert++ syntax for inserted/underlined text.
|
||||
*
|
||||
* Syntax: ++text++ → renders as inserted text (underline)
|
||||
*
|
||||
* Example:
|
||||
* - This is ++inserted++ text → This is <ins>inserted</ins> text
|
||||
*/
|
||||
|
||||
import { MarkdownConfig } from '@lezer/markdown';
|
||||
import { CharCode, createPairedDelimiterParser } from '../util';
|
||||
|
||||
/**
|
||||
* Insert extension for Lezer Markdown.
|
||||
*
|
||||
* Uses optimized factory function for O(n) single-pass parsing.
|
||||
*
|
||||
* Defines:
|
||||
* - Insert: The container node for inserted content
|
||||
* - InsertMark: The ++ delimiter marks
|
||||
*/
|
||||
export const Insert: MarkdownConfig = {
|
||||
defineNodes: [
|
||||
{ name: 'Insert' },
|
||||
{ name: 'InsertMark' }
|
||||
],
|
||||
parseInline: [
|
||||
createPairedDelimiterParser({
|
||||
name: 'Insert',
|
||||
nodeName: 'Insert',
|
||||
markName: 'InsertMark',
|
||||
delimChar: CharCode.Plus,
|
||||
isDouble: true,
|
||||
after: 'Emphasis'
|
||||
})
|
||||
]
|
||||
};
|
||||
|
||||
export default Insert;
|
||||
146
frontend/src/views/editor/extensions/markdown/syntax/math.ts
Normal file
146
frontend/src/views/editor/extensions/markdown/syntax/math.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Math extension for Lezer Markdown parser.
|
||||
*
|
||||
* Parses LaTeX math syntax:
|
||||
* - Inline math: $E=mc^2$ → renders as inline formula
|
||||
* - Block math: $$...$$ → renders as block formula (can be multi-line)
|
||||
*/
|
||||
|
||||
import { MarkdownConfig, InlineContext } from '@lezer/markdown';
|
||||
import { CharCode } from '../util';
|
||||
|
||||
/**
|
||||
* Parse block math ($$...$$).
|
||||
* Allows multi-line content and handles escaped $.
|
||||
*
|
||||
* @param cx - Inline context
|
||||
* @param pos - Start position (at first $)
|
||||
* @returns Position after element, or -1 if no match
|
||||
*/
|
||||
function parseBlockMath(cx: InlineContext, pos: number): number {
|
||||
const end = cx.end;
|
||||
|
||||
// Don't match $$$ or more
|
||||
if (cx.char(pos + 2) === CharCode.Dollar) return -1;
|
||||
|
||||
// Minimum: $$ + content + $$ = at least 5 chars
|
||||
const minEnd = pos + 4;
|
||||
if (end < minEnd) return -1;
|
||||
|
||||
// Search for closing $$
|
||||
const searchEnd = end - 1;
|
||||
for (let i = pos + 2; i < searchEnd; i++) {
|
||||
const char = cx.char(i);
|
||||
|
||||
// Skip escaped $ (backslash followed by any char)
|
||||
if (char === CharCode.Backslash) {
|
||||
i++; // Skip next char
|
||||
continue;
|
||||
}
|
||||
|
||||
// Found potential closing $$
|
||||
if (char === CharCode.Dollar) {
|
||||
const nextChar = cx.char(i + 1);
|
||||
if (nextChar !== CharCode.Dollar) continue;
|
||||
|
||||
// Don't match $$$
|
||||
if (i + 2 < end && cx.char(i + 2) === CharCode.Dollar) continue;
|
||||
|
||||
// Ensure content exists
|
||||
if (i === pos + 2) return -1;
|
||||
|
||||
// Create element with marks
|
||||
return cx.addElement(cx.elt('BlockMath', pos, i + 2, [
|
||||
cx.elt('BlockMathMark', pos, pos + 2),
|
||||
cx.elt('BlockMathMark', i, i + 2)
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse inline math ($...$).
|
||||
* Single line only, handles escaped $.
|
||||
*
|
||||
* @param cx - Inline context
|
||||
* @param pos - Start position (at $)
|
||||
* @returns Position after element, or -1 if no match
|
||||
*/
|
||||
function parseInlineMath(cx: InlineContext, pos: number): number {
|
||||
const end = cx.end;
|
||||
|
||||
// Don't match if preceded by backslash (escaped)
|
||||
if (pos > 0 && cx.char(pos - 1) === CharCode.Backslash) return -1;
|
||||
|
||||
// Minimum: $ + content + $ = at least 3 chars
|
||||
if (end < pos + 2) return -1;
|
||||
|
||||
// Search for closing $
|
||||
for (let i = pos + 1; i < end; i++) {
|
||||
const char = cx.char(i);
|
||||
|
||||
// Newline not allowed in inline math
|
||||
if (char === CharCode.Newline) return -1;
|
||||
|
||||
// Skip escaped $
|
||||
if (char === CharCode.Backslash && i + 1 < end && cx.char(i + 1) === CharCode.Dollar) {
|
||||
i++; // Skip next char
|
||||
continue;
|
||||
}
|
||||
|
||||
// Found potential closing $
|
||||
if (char === CharCode.Dollar) {
|
||||
// Don't match $$
|
||||
if (i + 1 < end && cx.char(i + 1) === CharCode.Dollar) continue;
|
||||
|
||||
// Ensure content exists
|
||||
if (i === pos + 1) return -1;
|
||||
|
||||
// Create element with marks
|
||||
return cx.addElement(cx.elt('InlineMath', pos, i + 1, [
|
||||
cx.elt('InlineMathMark', pos, pos + 1),
|
||||
cx.elt('InlineMathMark', i, i + 1)
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Math extension for Lezer Markdown.
|
||||
*
|
||||
* Defines:
|
||||
* - InlineMath: Inline math formula $...$
|
||||
* - InlineMathMark: The $ delimiter marks for inline
|
||||
* - BlockMath: Block math formula $$...$$
|
||||
* - BlockMathMark: The $$ delimiter marks for block
|
||||
*/
|
||||
export const Math: MarkdownConfig = {
|
||||
defineNodes: [
|
||||
{ name: 'InlineMath' },
|
||||
{ name: 'InlineMathMark' },
|
||||
{ name: 'BlockMath' },
|
||||
{ name: 'BlockMathMark' }
|
||||
],
|
||||
parseInline: [
|
||||
{
|
||||
name: 'Math',
|
||||
parse(cx, next, pos) {
|
||||
// Fast path: must start with $
|
||||
if (next !== CharCode.Dollar) return -1;
|
||||
|
||||
// Check for $$ (block math) vs $ (inline math)
|
||||
const isBlock = cx.char(pos + 1) === CharCode.Dollar;
|
||||
|
||||
return isBlock ? parseBlockMath(cx, pos) : parseInlineMath(cx, pos);
|
||||
},
|
||||
// Parse after emphasis to avoid conflicts
|
||||
after: 'Emphasis'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export default Math;
|
||||
@@ -1,34 +1,133 @@
|
||||
import { foldedRanges, syntaxTree } from '@codemirror/language';
|
||||
import type { SyntaxNodeRef, TreeCursor } from '@lezer/common';
|
||||
import { Decoration, EditorView } from '@codemirror/view';
|
||||
import {
|
||||
EditorState,
|
||||
SelectionRange,
|
||||
CharCategory,
|
||||
findClusterBreak
|
||||
} from '@codemirror/state';
|
||||
|
||||
// ============================================================================
|
||||
// Type Definitions (ProseMark style)
|
||||
// ============================================================================
|
||||
import { Decoration } from '@codemirror/view';
|
||||
import { EditorState } from '@codemirror/state';
|
||||
import type { InlineContext, InlineParser } from '@lezer/markdown';
|
||||
|
||||
/**
|
||||
* A range-like object with from and to properties.
|
||||
* ASCII character codes for common delimiters.
|
||||
*/
|
||||
export interface RangeLike {
|
||||
from: number;
|
||||
to: number;
|
||||
export const enum CharCode {
|
||||
Space = 32,
|
||||
Tab = 9,
|
||||
Newline = 10,
|
||||
Backslash = 92,
|
||||
Dollar = 36, // $
|
||||
Plus = 43, // +
|
||||
Equal = 61, // =
|
||||
OpenBracket = 91, // [
|
||||
CloseBracket = 93, // ]
|
||||
Caret = 94, // ^
|
||||
Colon = 58, // :
|
||||
Hyphen = 45, // -
|
||||
Underscore = 95, // _
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-computed lookup table for footnote ID characters.
|
||||
* Valid characters: 0-9, A-Z, a-z, _, -
|
||||
* Uses Uint8Array for memory efficiency and O(1) lookup.
|
||||
*/
|
||||
const FOOTNOTE_ID_CHARS = new Uint8Array(128);
|
||||
// Initialize lookup table (0-9: 48-57, A-Z: 65-90, a-z: 97-122, _: 95, -: 45)
|
||||
for (let i = 48; i <= 57; i++) FOOTNOTE_ID_CHARS[i] = 1; // 0-9
|
||||
for (let i = 65; i <= 90; i++) FOOTNOTE_ID_CHARS[i] = 1; // A-Z
|
||||
for (let i = 97; i <= 122; i++) FOOTNOTE_ID_CHARS[i] = 1; // a-z
|
||||
FOOTNOTE_ID_CHARS[95] = 1; // _
|
||||
FOOTNOTE_ID_CHARS[45] = 1; // -
|
||||
|
||||
/**
|
||||
* O(1) check if a character is valid for footnote ID.
|
||||
* @param code - ASCII character code
|
||||
* @returns True if valid footnote ID character
|
||||
*/
|
||||
export function isFootnoteIdChar(code: number): boolean {
|
||||
return code < 128 && FOOTNOTE_ID_CHARS[code] === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for paired delimiter parser factory.
|
||||
*/
|
||||
export interface PairedDelimiterConfig {
|
||||
/** Parser name */
|
||||
name: string;
|
||||
/** Node name for the container element */
|
||||
nodeName: string;
|
||||
/** Node name for the delimiter marks */
|
||||
markName: string;
|
||||
/** First delimiter character code */
|
||||
delimChar: number;
|
||||
/** Whether delimiter is doubled (e.g., == vs =) */
|
||||
isDouble: true;
|
||||
/** Whether to allow newlines in content */
|
||||
allowNewlines?: boolean;
|
||||
/** Parse order - after which parser */
|
||||
after?: string;
|
||||
/** Parse order - before which parser */
|
||||
before?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory function to create a paired delimiter inline parser.
|
||||
* Optimized with:
|
||||
* - Fast path early return
|
||||
* - Minimal function calls in loop
|
||||
* - Pre-computed delimiter length
|
||||
*
|
||||
* @param config - Parser configuration
|
||||
* @returns InlineParser for MarkdownConfig
|
||||
*/
|
||||
export function createPairedDelimiterParser(config: PairedDelimiterConfig): InlineParser {
|
||||
const { name, nodeName, markName, delimChar, allowNewlines = false, after, before } = config;
|
||||
const delimLen = 2; // Always double delimiter for these parsers
|
||||
|
||||
return {
|
||||
name,
|
||||
parse(cx: InlineContext, next: number, pos: number): number {
|
||||
// Fast path: check first character
|
||||
if (next !== delimChar) return -1;
|
||||
|
||||
// Check second delimiter character
|
||||
if (cx.char(pos + 1) !== delimChar) return -1;
|
||||
|
||||
// Don't match triple delimiter (e.g., ===, +++)
|
||||
if (cx.char(pos + 2) === delimChar) return -1;
|
||||
|
||||
// Calculate search bounds
|
||||
const searchEnd = cx.end - 1;
|
||||
const contentStart = pos + delimLen;
|
||||
|
||||
// Look for closing delimiter
|
||||
for (let i = contentStart; i < searchEnd; i++) {
|
||||
const char = cx.char(i);
|
||||
|
||||
// Check for newline (unless allowed)
|
||||
if (!allowNewlines && char === CharCode.Newline) return -1;
|
||||
|
||||
// Found potential closing delimiter
|
||||
if (char === delimChar && cx.char(i + 1) === delimChar) {
|
||||
// Don't match triple delimiter
|
||||
if (i + 2 < cx.end && cx.char(i + 2) === delimChar) continue;
|
||||
|
||||
// Create element with marks
|
||||
return cx.addElement(cx.elt(nodeName, pos, i + delimLen, [
|
||||
cx.elt(markName, pos, contentStart),
|
||||
cx.elt(markName, i, i + delimLen)
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
},
|
||||
...(after && { after }),
|
||||
...(before && { before })
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Tuple representation of a range [from, to].
|
||||
*/
|
||||
export type RangeTuple = [number, number];
|
||||
|
||||
// ============================================================================
|
||||
// Range Utilities
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Check if two ranges overlap (touch or intersect).
|
||||
* Based on the visual diagram on https://stackoverflow.com/a/25369187
|
||||
@@ -44,46 +143,6 @@ export function checkRangeOverlap(
|
||||
return range1[0] <= range2[1] && range2[0] <= range1[1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if two range-like objects touch or overlap.
|
||||
* ProseMark-style range comparison.
|
||||
*
|
||||
* @param a - First range
|
||||
* @param b - Second range
|
||||
* @returns True if ranges touch
|
||||
*/
|
||||
export function rangeTouchesRange(a: RangeLike, b: RangeLike): boolean {
|
||||
return a.from <= b.to && b.from <= a.to;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a selection touches a range.
|
||||
*
|
||||
* @param selection - Array of selection ranges
|
||||
* @param range - Range to check against
|
||||
* @returns True if any selection touches the range
|
||||
*/
|
||||
export function selectionTouchesRange(
|
||||
selection: readonly SelectionRange[],
|
||||
range: RangeLike
|
||||
): boolean {
|
||||
return selection.some((sel) => rangeTouchesRange(sel, range));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a range is inside another range (subset).
|
||||
*
|
||||
* @param parent - Parent (bigger) range
|
||||
* @param child - Child (smaller) range
|
||||
* @returns True if child is inside parent
|
||||
*/
|
||||
export function checkRangeSubset(
|
||||
parent: RangeTuple,
|
||||
child: RangeTuple
|
||||
): boolean {
|
||||
return child[0] >= parent[0] && child[1] <= parent[1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any of the editor cursors is in the given range.
|
||||
*
|
||||
@@ -100,159 +159,12 @@ export function isCursorInRange(
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tree Iteration Utilities
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Iterate over the syntax tree in the visible ranges of the document.
|
||||
*
|
||||
* @param view - Editor view
|
||||
* @param iterateFns - Object with `enter` and `leave` iterate function
|
||||
*/
|
||||
export function iterateTreeInVisibleRanges(
|
||||
view: EditorView,
|
||||
iterateFns: {
|
||||
enter(node: SyntaxNodeRef): boolean | void;
|
||||
leave?(node: SyntaxNodeRef): void;
|
||||
}
|
||||
): void {
|
||||
for (const { from, to } of view.visibleRanges) {
|
||||
syntaxTree(view.state).iterate({ ...iterateFns, from, to });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Iterate through child nodes of a cursor.
|
||||
* ProseMark-style tree traversal.
|
||||
*
|
||||
* @param cursor - Tree cursor to iterate
|
||||
* @param enter - Callback function, return true to stop iteration
|
||||
*/
|
||||
export function iterChildren(
|
||||
cursor: TreeCursor,
|
||||
enter: (cursor: TreeCursor) => boolean | undefined
|
||||
): void {
|
||||
if (!cursor.firstChild()) return;
|
||||
do {
|
||||
if (enter(cursor)) break;
|
||||
} while (cursor.nextSibling());
|
||||
cursor.parent();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Line Utilities
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Returns the lines of the editor that are in the given range and not folded.
|
||||
* This function is useful for adding line decorations to each line of a block node.
|
||||
*
|
||||
* @param view - Editor view
|
||||
* @param from - Start of the range
|
||||
* @param to - End of the range
|
||||
* @returns A list of line blocks that are in the range
|
||||
*/
|
||||
export function editorLines(
|
||||
view: EditorView,
|
||||
from: number,
|
||||
to: number
|
||||
) {
|
||||
let lines = view.viewportLineBlocks.filter((block) =>
|
||||
checkRangeOverlap([block.from, block.to], [from, to])
|
||||
);
|
||||
|
||||
const folded = foldedRanges(view.state).iter();
|
||||
while (folded.value) {
|
||||
lines = lines.filter(
|
||||
(line) =>
|
||||
!checkRangeOverlap(
|
||||
[folded.from, folded.to],
|
||||
[line.from, line.to]
|
||||
)
|
||||
);
|
||||
folded.next();
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get line numbers for a range.
|
||||
*
|
||||
* @param state - Editor state
|
||||
* @param from - Start position
|
||||
* @param to - End position
|
||||
* @returns Array of line numbers
|
||||
*/
|
||||
export function getLineNumbers(
|
||||
state: EditorState,
|
||||
from: number,
|
||||
to: number
|
||||
): number[] {
|
||||
const startLine = state.doc.lineAt(from).number;
|
||||
const endLine = state.doc.lineAt(to).number;
|
||||
const lines: number[] = [];
|
||||
|
||||
for (let i = startLine; i <= endLine; i++) {
|
||||
lines.push(i);
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Word Utilities (ProseMark style)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get the "WORD" at a position (vim-style WORD, including non-whitespace).
|
||||
*
|
||||
* @param state - Editor state
|
||||
* @param pos - Position in document
|
||||
* @returns Selection range of the WORD, or null if at whitespace
|
||||
*/
|
||||
export function stateWORDAt(
|
||||
state: EditorState,
|
||||
pos: number
|
||||
): SelectionRange | null {
|
||||
const { text, from, length } = state.doc.lineAt(pos);
|
||||
const cat = state.charCategorizer(pos);
|
||||
let start = pos - from;
|
||||
let end = pos - from;
|
||||
|
||||
while (start > 0) {
|
||||
const prev = findClusterBreak(text, start, false);
|
||||
if (cat(text.slice(prev, start)) === CharCategory.Space) break;
|
||||
start = prev;
|
||||
}
|
||||
|
||||
while (end < length) {
|
||||
const next = findClusterBreak(text, end);
|
||||
if (cat(text.slice(end, next)) === CharCategory.Space) break;
|
||||
end = next;
|
||||
}
|
||||
|
||||
return start === end
|
||||
? null
|
||||
: { from: start + from, to: end + from } as SelectionRange;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Decoration Utilities
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Decoration to simply hide anything (replace with nothing).
|
||||
*/
|
||||
export const invisibleDecoration = Decoration.replace({});
|
||||
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// Slug Generation
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Class for generating unique slugs from heading contents.
|
||||
*/
|
||||
@@ -288,5 +200,3 @@ export class Slugger {
|
||||
this.occurrences.clear();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user