🚧 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",
|
"@replit/codemirror-lang-svelte": "^6.0.0",
|
||||||
"@toml-tools/lexer": "^1.0.0",
|
"@toml-tools/lexer": "^1.0.0",
|
||||||
"@toml-tools/parser": "^1.0.0",
|
"@toml-tools/parser": "^1.0.0",
|
||||||
|
"@types/katex": "^0.16.7",
|
||||||
"codemirror": "^6.0.2",
|
"codemirror": "^6.0.2",
|
||||||
"codemirror-lang-elixir": "^4.0.0",
|
"codemirror-lang-elixir": "^4.0.0",
|
||||||
"colors-named": "^1.0.2",
|
"colors-named": "^1.0.2",
|
||||||
@@ -51,6 +52,7 @@
|
|||||||
"groovy-beautify": "^0.0.17",
|
"groovy-beautify": "^0.0.17",
|
||||||
"hsl-matcher": "^1.2.4",
|
"hsl-matcher": "^1.2.4",
|
||||||
"java-parser": "^3.0.1",
|
"java-parser": "^3.0.1",
|
||||||
|
"katex": "^0.16.25",
|
||||||
"linguist-languages": "^9.1.0",
|
"linguist-languages": "^9.1.0",
|
||||||
"marked": "^17.0.1",
|
"marked": "^17.0.1",
|
||||||
"mermaid": "^11.12.1",
|
"mermaid": "^11.12.1",
|
||||||
@@ -2831,6 +2833,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/linkify-it": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmmirror.com/@types/linkify-it/-/linkify-it-5.0.0.tgz",
|
"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",
|
"@replit/codemirror-lang-svelte": "^6.0.0",
|
||||||
"@toml-tools/lexer": "^1.0.0",
|
"@toml-tools/lexer": "^1.0.0",
|
||||||
"@toml-tools/parser": "^1.0.0",
|
"@toml-tools/parser": "^1.0.0",
|
||||||
|
"@types/katex": "^0.16.7",
|
||||||
"codemirror": "^6.0.2",
|
"codemirror": "^6.0.2",
|
||||||
"codemirror-lang-elixir": "^4.0.0",
|
"codemirror-lang-elixir": "^4.0.0",
|
||||||
"colors-named": "^1.0.2",
|
"colors-named": "^1.0.2",
|
||||||
@@ -65,6 +66,7 @@
|
|||||||
"groovy-beautify": "^0.0.17",
|
"groovy-beautify": "^0.0.17",
|
||||||
"hsl-matcher": "^1.2.4",
|
"hsl-matcher": "^1.2.4",
|
||||||
"java-parser": "^3.0.1",
|
"java-parser": "^3.0.1",
|
||||||
|
"katex": "^0.16.25",
|
||||||
"linguist-languages": "^9.1.0",
|
"linguist-languages": "^9.1.0",
|
||||||
"marked": "^17.0.1",
|
"marked": "^17.0.1",
|
||||||
"mermaid": "^11.12.1",
|
"mermaid": "^11.12.1",
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ 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 {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 {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";
|
||||||
@@ -116,7 +118,7 @@ export const LANGUAGES: LanguageInfo[] = [
|
|||||||
}),
|
}),
|
||||||
new LanguageInfo("md", "Markdown", markdown({
|
new LanguageInfo("md", "Markdown", markdown({
|
||||||
base: markdownLanguage,
|
base: markdownLanguage,
|
||||||
extensions: [Subscript, Superscript, Highlight, Footnote, Table],
|
extensions: [Subscript, Superscript, Highlight, Insert, Math, Footnote, Table],
|
||||||
completeHTMLTags: true,
|
completeHTMLTags: true,
|
||||||
pasteURLAsLink: true,
|
pasteURLAsLink: true,
|
||||||
htmlTagLanguage: html({
|
htmlTagLanguage: html({
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ 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 { insert } from './plugins/insert';
|
||||||
|
import { math } from './plugins/math';
|
||||||
import { footnote } from './plugins/footnote';
|
import { footnote } from './plugins/footnote';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -31,6 +33,8 @@ export const markdownExtensions: Extension = [
|
|||||||
inlineCode(),
|
inlineCode(),
|
||||||
subscriptSuperscript(),
|
subscriptSuperscript(),
|
||||||
highlight(),
|
highlight(),
|
||||||
|
insert(),
|
||||||
|
math(),
|
||||||
footnote(),
|
footnote(),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -216,7 +216,7 @@ class FootnoteRefWidget extends WidgetType {
|
|||||||
toDOM(): HTMLElement {
|
toDOM(): HTMLElement {
|
||||||
const span = document.createElement('span');
|
const span = document.createElement('span');
|
||||||
span.className = 'cm-footnote-ref';
|
span.className = 'cm-footnote-ref';
|
||||||
span.textContent = String(this.index);
|
span.textContent = `[${this.index}]`;
|
||||||
span.dataset.footnoteId = this.id;
|
span.dataset.footnoteId = this.id;
|
||||||
|
|
||||||
if (!this.hasDefinition) {
|
if (!this.hasDefinition) {
|
||||||
@@ -249,7 +249,7 @@ class InlineFootnoteWidget extends WidgetType {
|
|||||||
toDOM(): HTMLElement {
|
toDOM(): HTMLElement {
|
||||||
const span = document.createElement('span');
|
const span = document.createElement('span');
|
||||||
span.className = 'cm-inline-footnote-ref';
|
span.className = 'cm-inline-footnote-ref';
|
||||||
span.textContent = String(this.index);
|
span.textContent = `[${this.index}]`;
|
||||||
span.dataset.footnoteContent = this.content;
|
span.dataset.footnoteContent = this.content;
|
||||||
span.dataset.footnoteIndex = String(this.index);
|
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
|
// Decorations
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -318,15 +343,18 @@ function buildDecorations(view: EditorView): DecorationSet {
|
|||||||
const labelNode = node.getChild('FootnoteDefinitionLabel');
|
const labelNode = node.getChild('FootnoteDefinitionLabel');
|
||||||
|
|
||||||
if (!cursorInRange && marks.length >= 2 && labelNode) {
|
if (!cursorInRange && marks.length >= 2 && labelNode) {
|
||||||
// Hide the [^ and ]: marks
|
const id = view.state.sliceDoc(labelNode.from, labelNode.to);
|
||||||
decorations.push(invisibleDecoration.range(marks[0].from, marks[0].to));
|
|
||||||
decorations.push(invisibleDecoration.range(marks[1].from, marks[1].to));
|
// Hide the entire [^id]: part
|
||||||
|
decorations.push(invisibleDecoration.range(marks[0].from, marks[1].to));
|
||||||
|
|
||||||
// Style the label as definition marker
|
// Add widget to show [id]
|
||||||
|
const widget = new FootnoteDefLabelWidget(id);
|
||||||
decorations.push(
|
decorations.push(
|
||||||
Decoration.mark({
|
Decoration.widget({
|
||||||
class: 'cm-footnote-def-label',
|
widget,
|
||||||
}).range(labelNode.from, labelNode.to)
|
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,
|
arrow: true,
|
||||||
create: () => {
|
create: () => {
|
||||||
const dom = document.createElement('div');
|
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');
|
const imgEl = document.createElement('img');
|
||||||
imgEl.src = img.src;
|
imgEl.src = img.src;
|
||||||
imgEl.alt = img.alt;
|
imgEl.alt = img.alt;
|
||||||
|
|
||||||
|
imgEl.onload = () => {
|
||||||
|
dom.classList.remove('cm-image-loading');
|
||||||
|
};
|
||||||
imgEl.onerror = () => {
|
imgEl.onerror = () => {
|
||||||
|
spinner.remove();
|
||||||
imgEl.remove();
|
imgEl.remove();
|
||||||
dom.textContent = 'Failed to load image';
|
dom.textContent = 'Failed to load image';
|
||||||
|
dom.classList.remove('cm-image-loading');
|
||||||
dom.classList.add('cm-image-tooltip-error');
|
dom.classList.add('cm-image-tooltip-error');
|
||||||
};
|
};
|
||||||
|
|
||||||
dom.append(imgEl);
|
dom.append(spinner, imgEl);
|
||||||
return { dom };
|
return { dom };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -150,15 +158,50 @@ const theme = EditorView.baseTheme({
|
|||||||
},
|
},
|
||||||
'.cm-image-indicator:hover': { opacity: '1' },
|
'.cm-image-indicator:hover': { opacity: '1' },
|
||||||
'.cm-image-tooltip': {
|
'.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)',
|
border: '1px solid var(--border-color)',
|
||||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
|
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
|
||||||
'& img': {
|
'& img': {
|
||||||
display: 'block',
|
display: 'block',
|
||||||
maxWidth: '60vw',
|
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': {
|
'.cm-image-tooltip-error': {
|
||||||
padding: '16px 24px',
|
padding: '16px 24px',
|
||||||
fontSize: '12px',
|
fontSize: '12px',
|
||||||
@@ -169,8 +212,8 @@ const theme = EditorView.baseTheme({
|
|||||||
borderBottomColor: 'var(--border-color) !important'
|
borderBottomColor: 'var(--border-color) !important'
|
||||||
},
|
},
|
||||||
'.cm-tooltip-arrow:after': {
|
'.cm-tooltip-arrow:after': {
|
||||||
borderTopColor: 'var(--bg-secondary) !important',
|
borderTopColor: '#fff !important',
|
||||||
borderBottomColor: 'var(--bg-secondary) !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.
|
* - 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 {
|
function parseInlineFootnote(cx: InlineContext, pos: number): number {
|
||||||
OpenBracket = 91, // [
|
const end = cx.end;
|
||||||
CloseBracket = 93, // ]
|
|
||||||
Caret = 94, // ^
|
// Minimum: ^[ + content + ] = at least 4 chars
|
||||||
Colon = 58, // :
|
if (end < pos + 3) return -1;
|
||||||
Space = 32,
|
|
||||||
Tab = 9,
|
// Track bracket depth for nested brackets
|
||||||
Newline = 10,
|
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.
|
* Parse footnote reference [^id].
|
||||||
* Allows: letters, numbers, underscore, hyphen
|
*
|
||||||
|
* @param cx - Inline context
|
||||||
|
* @param pos - Start position (at [)
|
||||||
|
* @returns Position after element, or -1 if no match
|
||||||
*/
|
*/
|
||||||
function isFootnoteIdChar(code: number): boolean {
|
function parseFootnoteReference(cx: InlineContext, pos: number): number {
|
||||||
return (
|
const end = cx.end;
|
||||||
(code >= 48 && code <= 57) || // 0-9
|
|
||||||
(code >= 65 && code <= 90) || // A-Z
|
// Minimum: [^ + id + ] = at least 4 chars
|
||||||
(code >= 97 && code <= 122) || // a-z
|
if (end < pos + 3) return -1;
|
||||||
code === 95 || // _
|
|
||||||
code === 45 // -
|
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: [
|
parseInline: [
|
||||||
// Inline footnote must be parsed before Superscript to handle ^[ pattern
|
|
||||||
{
|
{
|
||||||
name: 'InlineFootnote',
|
name: 'InlineFootnote',
|
||||||
parse(cx, next, pos) {
|
parse(cx, next, pos) {
|
||||||
// Check for ^[ pattern
|
// Fast path: must start with ^[
|
||||||
if (next !== Ch.Caret || cx.char(pos + 1) !== Ch.OpenBracket) {
|
if (next !== CharCode.Caret || cx.char(pos + 1) !== CharCode.OpenBracket) {
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
return parseInlineFootnote(cx, pos);
|
||||||
// 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',
|
before: 'Superscript',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'FootnoteReference',
|
name: 'FootnoteReference',
|
||||||
parse(cx, next, pos) {
|
parse(cx, next, pos) {
|
||||||
// Check for [^ pattern
|
// Fast path: must start with [^
|
||||||
if (next !== Ch.OpenBracket || cx.char(pos + 1) !== Ch.Caret) {
|
if (next !== CharCode.OpenBracket || cx.char(pos + 1) !== CharCode.Caret) {
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
return parseFootnoteReference(cx, pos);
|
||||||
// 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',
|
before: 'Link',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -197,90 +244,16 @@ export const Footnote: MarkdownConfig = {
|
|||||||
{
|
{
|
||||||
name: 'FootnoteDefinition',
|
name: 'FootnoteDefinition',
|
||||||
parse(cx: BlockContext, line: Line): boolean {
|
parse(cx: BlockContext, line: Line): boolean {
|
||||||
// Must start at the beginning of a line
|
// Fast path: must start with [^
|
||||||
// Check for [^ pattern
|
if (line.text.charCodeAt(0) !== CharCode.OpenBracket ||
|
||||||
const text = line.text;
|
line.text.charCodeAt(1) !== CharCode.Caret) {
|
||||||
if (text.charCodeAt(0) !== Ch.OpenBracket ||
|
|
||||||
text.charCodeAt(1) !== Ch.Caret) {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
return parseFootnoteDefinition(cx, line);
|
||||||
// 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',
|
before: 'LinkReference',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Footnote;
|
export default Footnote;
|
||||||
|
|
||||||
|
|||||||
@@ -10,10 +10,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { MarkdownConfig } from '@lezer/markdown';
|
import { MarkdownConfig } from '@lezer/markdown';
|
||||||
|
import { CharCode, createPairedDelimiterParser } from '../util';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Highlight extension for Lezer Markdown.
|
* Highlight extension for Lezer Markdown.
|
||||||
*
|
|
||||||
* Defines:
|
* Defines:
|
||||||
* - Highlight: The container node for highlighted content
|
* - Highlight: The container node for highlighted content
|
||||||
* - HighlightMark: The == delimiter marks
|
* - HighlightMark: The == delimiter marks
|
||||||
@@ -23,50 +23,16 @@ export const Highlight: MarkdownConfig = {
|
|||||||
{ name: 'Highlight' },
|
{ name: 'Highlight' },
|
||||||
{ name: 'HighlightMark' }
|
{ name: 'HighlightMark' }
|
||||||
],
|
],
|
||||||
parseInline: [{
|
parseInline: [
|
||||||
name: 'Highlight',
|
createPairedDelimiterParser({
|
||||||
parse(cx, next, pos) {
|
name: 'Highlight',
|
||||||
// Check for == delimiter (= is ASCII 61)
|
nodeName: 'Highlight',
|
||||||
if (next !== 61 || cx.char(pos + 1) !== 61) {
|
markName: 'HighlightMark',
|
||||||
return -1;
|
delimChar: CharCode.Equal,
|
||||||
}
|
isDouble: true,
|
||||||
|
after: 'Emphasis'
|
||||||
// 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
|
|
||||||
after: 'Emphasis'
|
|
||||||
}]
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Highlight;
|
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 { Decoration } from '@codemirror/view';
|
||||||
import type { SyntaxNodeRef, TreeCursor } from '@lezer/common';
|
import { EditorState } from '@codemirror/state';
|
||||||
import { Decoration, EditorView } from '@codemirror/view';
|
import type { InlineContext, InlineParser } from '@lezer/markdown';
|
||||||
import {
|
|
||||||
EditorState,
|
|
||||||
SelectionRange,
|
|
||||||
CharCategory,
|
|
||||||
findClusterBreak
|
|
||||||
} from '@codemirror/state';
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Type Definitions (ProseMark style)
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A range-like object with from and to properties.
|
* ASCII character codes for common delimiters.
|
||||||
*/
|
*/
|
||||||
export interface RangeLike {
|
export const enum CharCode {
|
||||||
from: number;
|
Space = 32,
|
||||||
to: number;
|
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].
|
* Tuple representation of a range [from, to].
|
||||||
*/
|
*/
|
||||||
export type RangeTuple = [number, number];
|
export type RangeTuple = [number, number];
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Range Utilities
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if two ranges overlap (touch or intersect).
|
* Check if two ranges overlap (touch or intersect).
|
||||||
* Based on the visual diagram on https://stackoverflow.com/a/25369187
|
* 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];
|
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.
|
* 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).
|
* Decoration to simply hide anything (replace with nothing).
|
||||||
*/
|
*/
|
||||||
export const invisibleDecoration = Decoration.replace({});
|
export const invisibleDecoration = Decoration.replace({});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Slug Generation
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Class for generating unique slugs from heading contents.
|
* Class for generating unique slugs from heading contents.
|
||||||
*/
|
*/
|
||||||
@@ -288,5 +200,3 @@ export class Slugger {
|
|||||||
this.occurrences.clear();
|
this.occurrences.clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user