feat(markdown): add LaTeX math support

This commit is contained in:
alam00000 committed 2026-10-26 15:00:18 +05:30
1 parent f7a8a60440
commit 9c796a873c
11 files changed
+1592 -1464

No files matched your search

+151 -333
View File
@@ -1,4 +1,7 @@
import MarkdownIt from 'markdown-it';
import MarkdownIt, {
type MarkdownIt as MarkdownItParser,
type StateInline,
} from 'markdown-it';
import DOMPurify from 'dompurify';
import hljs from 'highlight.js/lib/core';
import javascript from 'highlight.js/lib/languages/javascript';
@@ -17,18 +20,13 @@ import go from 'highlight.js/lib/languages/go';
import rust from 'highlight.js/lib/languages/rust';
import yaml from 'highlight.js/lib/languages/yaml';
import 'highlight.js/styles/github.css';
import mermaid from 'mermaid';
import sub from 'markdown-it-sub';
import sup from 'markdown-it-sup';
import footnote from 'markdown-it-footnote';
import deflist from 'markdown-it-deflist';
import abbr from 'markdown-it-abbr';
import { full as emoji } from 'markdown-it-emoji';
import ins from 'markdown-it-ins';
import mark from 'markdown-it-mark';
import taskLists from 'markdown-it-task-lists';
import anchor from 'markdown-it-anchor';
import tocDoneRight from 'markdown-it-toc-done-right';
import type { Mermaid } from 'mermaid';
import katex from 'katex';
import 'katex/dist/katex.min.css';
import { fullEmoji } from '@mdit/plugin-emoji';
import { tasklist } from '@mdit/plugin-tasklist';
import { katex as katexPlugin } from '@mdit/plugin-katex-slim';
import { useMarkdownPlugins } from './markdown-plugins';
import { applyTranslations, trHtml, trText } from '../i18n/i18n';
import type {
WindowWithLucide,
@@ -77,6 +75,36 @@ export type { MarkdownItOptions } from '@/types';
const MIN_TABLE_ZOOM = 0.6;
const TABLE_BREAK_CLASS = 'md-table-break-anywhere';
const PREVIEW_SANITIZE_OPTIONS = {
ADD_ATTR: ['target'],
FORBID_TAGS: ['style', 'form', 'dialog'],
FORBID_ATTR: ['popover'],
};
const ESCAPED_MATH: ReadonlyArray<[string, string, boolean]> = [
['\\\\[', '\\\\]', true],
['\\\\(', '\\\\)', false],
['\\[', '\\]', true],
];
function escapedMath(state: StateInline, silent: boolean): boolean {
for (const [open, close, display] of ESCAPED_MATH) {
if (!state.src.startsWith(open, state.pos)) continue;
const start = state.pos + open.length;
const end = state.src.indexOf(close, start);
if (end <= start || end + close.length > state.posMax) return false;
if (!silent) {
const token = state.push(
display ? 'math_display_inline' : 'math_inline',
'math',
0
);
token.content = state.src.slice(start, end);
}
state.pos = end + close.length;
return true;
}
return false;
}
const DEFAULT_MARKDOWN = `# Welcome to BentoPDF Markdown Editor
@@ -131,6 +159,32 @@ def fibonacci(n):
| Tables | ✓ | With alignment |
| Emoji | ✓ | :white_check_mark: |
| Mermaid | ✓ | Diagrams! |
| Math | ✓ | LaTeX via KaTeX |
## Math
Inline math with dollar signs like $E = mc^2$, or with brackets like \\(a^2 + b^2 = c^2\\).
Display math with double dollar signs:
$$
\\int_0^\\infty e^{-x^2}\\,dx = \\frac{\\sqrt{\\pi}}{2}
$$
Or with square brackets:
\\[
x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}
\\]
Or in a math code block:
\`\`\`math
\\begin{pmatrix} a & b \\\\ c & d \\end{pmatrix}
\\begin{pmatrix} x \\\\ y \\end{pmatrix}
=
\\begin{pmatrix} ax + by \\\\ cx + dy \\end{pmatrix}
\`\`\`
## Mermaid Diagrams
@@ -265,13 +319,13 @@ Start editing to see the magic happen!
export class MarkdownEditor {
private container: HTMLElement;
private md: MarkdownIt;
private md: MarkdownItParser;
private editor: HTMLTextAreaElement | null = null;
private preview: HTMLElement | null = null;
private onBack?: () => void;
private syncScroll: boolean = false;
private isSyncing: boolean = false;
private mermaidInitialized: boolean = false;
private mermaid: Promise<Mermaid> | null = null;
private mdOptions: MarkdownItOptions = {
html: true,
breaks: false,
@@ -283,7 +337,6 @@ export class MarkdownEditor {
this.container = container;
this.onBack = options.onBack;
this.initMermaid();
this.md = this.createMarkdownIt();
this.configureLinkRenderer();
@@ -296,18 +349,26 @@ export class MarkdownEditor {
}
}
private initMermaid(): void {
if (!this.mermaidInitialized) {
mermaid.initialize({
startOnLoad: false,
theme: 'default',
securityLevel: 'strict',
htmlLabels: false,
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
});
this.mermaidInitialized = true;
}
private loadMermaid(): Promise<Mermaid> {
this.mermaid ??= import('mermaid').then(
({ default: mermaid }) => {
mermaid.initialize({
startOnLoad: false,
theme: 'default',
look: 'classic',
securityLevel: 'strict',
htmlLabels: false,
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
});
return mermaid;
},
(error: unknown) => {
this.mermaid = null;
throw error;
}
);
return this.mermaid;
}
private configureLinkRenderer(): void {
@@ -338,6 +399,44 @@ export class MarkdownEditor {
private render(): void {
this.container.innerHTML = `
<!-- Settings Modal (hidden by default) -->
<div class="md-editor-modal-overlay" id="mdSettingsModal" style="display: none;">
<div class="md-editor-modal">
<div class="md-editor-modal-header">
<h2 class="md-editor-modal-title" data-i18n="tools:markdownToPdf.settingsTitle">Markdown Settings</h2>
<button class="md-editor-modal-close" id="mdCloseSettings">
<i data-lucide="x" width="20" height="20"></i>
</button>
</div>
<div class="md-editor-settings-group">
<h3 data-i18n="tools:markdownToPdf.settingsPreset">Preset</h3>
<select id="mdPreset">
<option value="default" selected data-i18n="tools:markdownToPdf.presetDefault">Default (GFM-like)</option>
<option value="commonmark" data-i18n="tools:markdownToPdf.presetCommonmark">CommonMark (strict)</option>
<option value="zero" data-i18n="tools:markdownToPdf.presetZero">Minimal (no features)</option>
</select>
</div>
<div class="md-editor-settings-group">
<h3 data-i18n="tools:markdownToPdf.settingsOptions">Markdown Options</h3>
<label class="md-editor-checkbox">
<input type="checkbox" id="mdOptHtml" ${this.mdOptions.html ? 'checked' : ''} />
<span data-i18n="tools:markdownToPdf.optAllowHtml">Allow HTML tags</span>
</label>
<label class="md-editor-checkbox">
<input type="checkbox" id="mdOptBreaks" ${this.mdOptions.breaks ? 'checked' : ''} />
<span data-i18n="tools:markdownToPdf.optBreaks">Convert newlines to &lt;br&gt;</span>
</label>
<label class="md-editor-checkbox">
<input type="checkbox" id="mdOptLinkify" ${this.mdOptions.linkify ? 'checked' : ''} />
<span data-i18n="tools:markdownToPdf.optLinkify">Auto-convert URLs to links</span>
</label>
<label class="md-editor-checkbox">
<input type="checkbox" id="mdOptTypographer" ${this.mdOptions.typographer ? 'checked' : ''} />
<span data-i18n="tools:markdownToPdf.optTypographer">Typographer (smart quotes, etc.)</span>
</label>
</div>
</div>
</div>
<div class="md-editor light-mode">
<div class="md-editor-wrapper">
<div class="md-editor-header">
@@ -384,44 +483,6 @@ export class MarkdownEditor {
</div>
</div>
<!-- Settings Modal (hidden by default) -->
<div class="md-editor-modal-overlay" id="mdSettingsModal" style="display: none;">
<div class="md-editor-modal">
<div class="md-editor-modal-header">
<h2 class="md-editor-modal-title" data-i18n="tools:markdownToPdf.settingsTitle">Markdown Settings</h2>
<button class="md-editor-modal-close" id="mdCloseSettings">
<i data-lucide="x" width="20" height="20"></i>
</button>
</div>
<div class="md-editor-settings-group">
<h3 data-i18n="tools:markdownToPdf.settingsPreset">Preset</h3>
<select id="mdPreset">
<option value="default" selected data-i18n="tools:markdownToPdf.presetDefault">Default (GFM-like)</option>
<option value="commonmark" data-i18n="tools:markdownToPdf.presetCommonmark">CommonMark (strict)</option>
<option value="zero" data-i18n="tools:markdownToPdf.presetZero">Minimal (no features)</option>
</select>
</div>
<div class="md-editor-settings-group">
<h3 data-i18n="tools:markdownToPdf.settingsOptions">Markdown Options</h3>
<label class="md-editor-checkbox">
<input type="checkbox" id="mdOptHtml" ${this.mdOptions.html ? 'checked' : ''} />
<span data-i18n="tools:markdownToPdf.optAllowHtml">Allow HTML tags</span>
</label>
<label class="md-editor-checkbox">
<input type="checkbox" id="mdOptBreaks" ${this.mdOptions.breaks ? 'checked' : ''} />
<span data-i18n="tools:markdownToPdf.optBreaks">Convert newlines to &lt;br&gt;</span>
</label>
<label class="md-editor-checkbox">
<input type="checkbox" id="mdOptLinkify" ${this.mdOptions.linkify ? 'checked' : ''} />
<span data-i18n="tools:markdownToPdf.optLinkify">Auto-convert URLs to links</span>
</label>
<label class="md-editor-checkbox">
<input type="checkbox" id="mdOptTypographer" ${this.mdOptions.typographer ? 'checked' : ''} />
<span data-i18n="tools:markdownToPdf.optTypographer">Typographer (smart quotes, etc.)</span>
</label>
</div>
</div>
</div>
`;
this.editor = document.getElementById('mdTextarea') as HTMLTextAreaElement;
@@ -646,9 +707,9 @@ export class MarkdownEditor {
}
}
private createMarkdownIt(): MarkdownIt {
private createMarkdownIt(): MarkdownItParser {
// Use preset if commonmark or zero
let md: MarkdownIt;
let md: MarkdownItParser;
if (this.currentPreset === 'commonmark') {
md = new MarkdownIt('commonmark');
} else if (this.currentPreset === 'zero') {
@@ -676,17 +737,26 @@ export class MarkdownEditor {
// Apply plugins only for default preset (plugins may not work well with commonmark/zero)
if (this.currentPreset === 'default') {
md.use(sub) // Subscript: ~text~ -> <sub>text</sub>
.use(sup) // Superscript: ^text^ -> <sup>text</sup>
.use(footnote) // Footnotes: [^1] and [^1]: footnote text
.use(deflist) // Definition lists
.use(abbr) // Abbreviations: *[abbr]: full text
.use(emoji) // Emoji: :smile: -> 😄
.use(ins) // Inserted text: ++text++ -> <ins>text</ins>
.use(mark) // Marked text: ==text== -> <mark>text</mark>
.use(taskLists, { enabled: true, label: false }) // Task lists: - [x] done
.use(anchor, { permalink: false }) // Header anchors
.use(tocDoneRight); // Table of contents: ${toc}
useMarkdownPlugins(md)
.use(fullEmoji) // Emoji: :smile: -> 😄
.use(tasklist, {
disabled: false,
label: false,
containerClass: 'contains-task-list',
}) // Task lists: - [x] done
.use(katexPlugin, {
delimiters: 'all' as const,
mathFence: true,
throwOnError: false,
});
md.inline.ruler.before('escape', 'math_escaped', escapedMath);
md.renderer.rules.math_display_inline = (tokens, idx) =>
katex.renderToString(tokens[idx].content, {
displayMode: true,
throwOnError: false,
strict: (code: string) =>
code === 'newLineInDisplayMode' ? 'ignore' : 'warn',
});
}
return md;
@@ -703,9 +773,7 @@ export class MarkdownEditor {
const markdown = this.editor.value;
const html = this.md.render(markdown);
this.preview.innerHTML = DOMPurify.sanitize(html, {
ADD_ATTR: ['target'],
});
this.preview.innerHTML = DOMPurify.sanitize(html, PREVIEW_SANITIZE_OPTIONS);
this.renderMermaidDiagrams();
}
@@ -724,6 +792,7 @@ export class MarkdownEditor {
if (pre && code.trim()) {
try {
const id = `mermaid-diagram-${i}-${Date.now()}`;
const mermaid = await this.loadMermaid();
const { svg } = await mermaid.render(id, code.trim());
const wrapper = document.createElement('div');
@@ -758,12 +827,6 @@ export class MarkdownEditor {
return this.editor?.value || '';
}
public getHtml(): string {
return DOMPurify.sanitize(this.md.render(this.getContent()), {
ADD_ATTR: ['target'],
});
}
private fitTablesToPage(printing: boolean): void {
if (!this.preview) return;
const available = this.preview.clientWidth;
@@ -789,251 +852,6 @@ export class MarkdownEditor {
window.print();
}
private getStyledHtml(): string {
const content = this.getHtml();
return `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
font-size: 14px;
line-height: 1.6;
color: #333;
max-width: 800px;
margin: 0 auto;
padding: 40px 20px;
}
h1, h2, h3, h4, h5, h6 {
margin-top: 1.5em;
margin-bottom: 0.5em;
font-weight: 600;
line-height: 1.25;
}
h1 { font-size: 2em; border-bottom: 1px solid #eee; padding-bottom: 0.3em; }
h2 { font-size: 1.5em; border-bottom: 1px solid #eee; padding-bottom: 0.3em; }
h3 { font-size: 1.25em; }
h4 { font-size: 1em; }
p { margin: 1em 0; }
a { color: #0366d6; text-decoration: none; }
a:hover { text-decoration: underline; }
code {
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 0.9em;
background: #f6f8fa;
padding: 0.2em 0.4em;
border-radius: 3px;
}
pre {
background: #f6f8fa;
padding: 16px;
overflow: auto;
border-radius: 6px;
line-height: 1.45;
}
pre code {
background: none;
padding: 0;
}
blockquote {
margin: 1em 0;
padding: 0 1em;
color: #6a737d;
border-left: 4px solid #dfe2e5;
}
ul, ol {
margin: 1em 0;
padding-left: 2em;
}
li { margin: 0.25em 0; }
table {
border-collapse: collapse;
width: 100%;
margin: 1em 0;
}
th, td {
border: 1px solid #dfe2e5;
padding: 8px 12px;
text-align: left;
}
th {
background: #f6f8fa;
font-weight: 600;
}
tr:nth-child(even) { background: #f6f8fa; }
hr {
border: none;
border-top: 1px solid #eee;
margin: 2em 0;
}
img {
max-width: 100%;
height: auto;
}
/* Syntax highlighting - GitHub style */
.hljs {
color: #24292e;
background: #f6f8fa;
}
.hljs-comment,
.hljs-quote {
color: #6a737d;
font-style: italic;
}
.hljs-keyword,
.hljs-selector-tag,
.hljs-subst {
color: #d73a49;
}
.hljs-number,
.hljs-literal,
.hljs-variable,
.hljs-template-variable,
.hljs-tag .hljs-attr {
color: #005cc5;
}
.hljs-string,
.hljs-doctag {
color: #032f62;
}
.hljs-title,
.hljs-section,
.hljs-selector-id {
color: #6f42c1;
font-weight: bold;
}
.hljs-type,
.hljs-class .hljs-title {
color: #6f42c1;
}
.hljs-tag,
.hljs-name,
.hljs-attribute {
color: #22863a;
}
.hljs-regexp,
.hljs-link {
color: #032f62;
}
.hljs-symbol,
.hljs-bullet {
color: #e36209;
}
.hljs-built_in,
.hljs-builtin-name {
color: #005cc5;
}
.hljs-meta {
color: #6a737d;
font-weight: bold;
}
.hljs-deletion {
color: #b31d28;
background-color: #ffeef0;
}
.hljs-addition {
color: #22863a;
background-color: #f0fff4;
}
/* Plugin styles */
mark {
background-color: #fff3cd;
padding: 0.1em 0.2em;
border-radius: 2px;
}
ins {
text-decoration: none;
background-color: #d4edda;
padding: 0.1em 0.2em;
border-radius: 2px;
}
sub, sup {
font-size: 0.75em;
}
.task-list-item {
list-style-type: none;
margin-left: -1.5em;
}
.task-list-item input[type="checkbox"] {
margin-right: 0.5em;
}
.footnotes {
margin-top: 2em;
padding-top: 1em;
border-top: 1px solid #eee;
font-size: 0.9em;
}
.footnotes-sep {
display: none;
}
.footnote-ref {
font-size: 0.75em;
vertical-align: super;
}
.footnote-backref {
font-size: 0.75em;
margin-left: 0.25em;
}
dl {
margin: 1em 0;
}
dt {
font-weight: 600;
margin-top: 1em;
}
dd {
margin-left: 2em;
margin-top: 0.25em;
color: #6a737d;
}
abbr {
text-decoration: underline dotted;
cursor: help;
}
.table-of-contents {
background: #f6f8fa;
padding: 1em 1.5em;
border-radius: 6px;
margin: 1em 0;
}
.table-of-contents ul {
margin: 0;
padding-left: 1.5em;
}
.table-of-contents li {
margin: 0.25em 0;
}
/* Mermaid diagrams */
.mermaid-diagram {
display: flex;
justify-content: center;
margin: 1.5em 0;
padding: 1em;
background: #f6f8fa;
border-radius: 6px;
}
.mermaid-diagram svg {
max-width: 100%;
height: auto;
}
.mermaid-error {
color: #cb2431;
background: #ffeef0;
padding: 1em;
border-radius: 6px;
font-family: monospace;
font-size: 0.9em;
}
</style>
</head>
<body>
${content}
</body>
</html>`;
}
private applyI18n(): void {
// Apply translations to elements within this component
applyTranslations();