import MarkdownIt from 'markdown-it';
import hljs from 'highlight.js/lib/core';
import javascript from 'highlight.js/lib/languages/javascript';
import typescript from 'highlight.js/lib/languages/typescript';
import python from 'highlight.js/lib/languages/python';
import css from 'highlight.js/lib/languages/css';
import xml from 'highlight.js/lib/languages/xml';
import json from 'highlight.js/lib/languages/json';
import bash from 'highlight.js/lib/languages/bash';
import markdownLang from 'highlight.js/lib/languages/markdown';
import sql from 'highlight.js/lib/languages/sql';
import java from 'highlight.js/lib/languages/java';
import csharp from 'highlight.js/lib/languages/csharp';
import cpp from 'highlight.js/lib/languages/cpp';
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 { applyTranslations } from '../i18n/i18n';
// Register highlight.js languages
hljs.registerLanguage('javascript', javascript);
hljs.registerLanguage('js', javascript);
hljs.registerLanguage('typescript', typescript);
hljs.registerLanguage('ts', typescript);
hljs.registerLanguage('python', python);
hljs.registerLanguage('py', python);
hljs.registerLanguage('css', css);
hljs.registerLanguage('html', xml);
hljs.registerLanguage('xml', xml);
hljs.registerLanguage('json', json);
hljs.registerLanguage('bash', bash);
hljs.registerLanguage('sh', bash);
hljs.registerLanguage('shell', bash);
hljs.registerLanguage('markdown', markdownLang);
hljs.registerLanguage('md', markdownLang);
hljs.registerLanguage('sql', sql);
hljs.registerLanguage('java', java);
hljs.registerLanguage('csharp', csharp);
hljs.registerLanguage('cs', csharp);
hljs.registerLanguage('cpp', cpp);
hljs.registerLanguage('c', cpp);
hljs.registerLanguage('go', go);
hljs.registerLanguage('rust', rust);
hljs.registerLanguage('yaml', yaml);
hljs.registerLanguage('yml', yaml);
export interface MarkdownEditorOptions {
/** Initial markdown content */
initialContent?: string;
/** Callback when user wants to go back */
onBack?: () => void;
}
export interface MarkdownItOptions {
/** Enable HTML tags in source */
html: boolean;
/** Convert '\n' in paragraphs into
*/
breaks: boolean;
/** Autoconvert URL-like text to links */
linkify: boolean;
/** Enable some language-neutral replacement + quotes beautification */
typographer: boolean;
/** Highlight function for fenced code blocks */
highlight?: (str: string, lang: string) => string;
}
const DEFAULT_MARKDOWN = `# Welcome to BentoPDF Markdown Editor
This is a **live preview** markdown editor with full plugin support.
\${toc}
## Basic Formatting
- **Bold** and *italic* text
- ~~Strikethrough~~ text
- [Links](https://bentopdf.com)
- ==Highlighted text== using mark
- ++Inserted text++ using ins
- H~2~O for subscript
- E=mc^2^ for superscript
## Task Lists
- [x] Completed task
- [x] Another done item
- [ ] Pending task
- [ ] Future work
## Emoji Support :rocket:
Use emoji shortcodes: :smile: :heart: :thumbsup: :star: :fire:
## Code with Syntax Highlighting
\`\`\`javascript
function greet(name) {
console.log(\`Hello, \${name}!\`);
return { message: 'Welcome!' };
}
\`\`\`
\`\`\`python
def fibonacci(n):
if n <= 1:
return n
return fibonacci(n-1) + fibonacci(n-2)
\`\`\`
## Tables
| Feature | Supported | Notes |
|---------|:---------:|-------|
| Headers | ✓ | Multiple levels |
| Lists | ✓ | Ordered & unordered |
| Code | ✓ | With highlighting |
| Tables | ✓ | With alignment |
| Emoji | ✓ | :white_check_mark: |
| Mermaid | ✓ | Diagrams! |
## Mermaid Diagrams
### Flowchart
\`\`\`mermaid
graph TD
A[Start] --> B{Decision}
B -->|Yes| C[OK]
B -->|No| D[Cancel]
\`\`\`
### Sequence Diagram
\`\`\`mermaid
sequenceDiagram
participant User
participant BentoPDF
participant Server
User->>BentoPDF: Upload PDF
BentoPDF->>BentoPDF: Process locally
BentoPDF-->>User: Download result
Note over BentoPDF: No server needed!
\`\`\`
### Pie Chart
\`\`\`mermaid
pie title PDF Tools Usage
"Merge" : 35
"Compress" : 25
"Convert" : 20
"Edit" : 15
"Other" : 5
\`\`\`
### Class Diagram
\`\`\`mermaid
classDiagram
class PDFDocument {
+String title
+int pageCount
+merge()
+split()
+compress()
}
class Page {
+int number
+rotate()
+crop()
}
PDFDocument "1" --> "*" Page
\`\`\`
### Gantt Chart
\`\`\`mermaid
gantt
title Project Timeline
dateFormat YYYY-MM-DD
section Planning
Research :a1, 2024-01-01, 7d
Design :a2, after a1, 5d
section Development
Implementation :a3, after a2, 14d
Testing :a4, after a3, 7d
\`\`\`
### Entity Relationship
\`\`\`mermaid
erDiagram
USER ||--o{ DOCUMENT : uploads
DOCUMENT ||--|{ PAGE : contains
DOCUMENT {
string id
string name
date created
}
PAGE {
int number
string content
}
\`\`\`
### Mindmap
\`\`\`mermaid
mindmap
root((BentoPDF))
Convert
Word to PDF
Excel to PDF
Image to PDF
Edit
Merge
Split
Compress
Secure
Encrypt
Sign
Watermark
\`\`\`
## Footnotes
Here's a sentence with a footnote[^1].
## Definition Lists
Term 1
: Definition for term 1
Term 2
: Definition for term 2
: Another definition for term 2
## Abbreviations
The HTML specification is maintained by the W3C.
*[HTML]: Hyper Text Markup Language
*[W3C]: World Wide Web Consortium
---
Start editing to see the magic happen!
[^1]: This is the footnote content.
`;
export class MarkdownEditor {
private container: HTMLElement;
private md: MarkdownIt;
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 mdOptions: MarkdownItOptions = {
html: true,
breaks: false,
linkify: true,
typographer: true
};
constructor(container: HTMLElement, options: MarkdownEditorOptions) {
this.container = container;
this.onBack = options.onBack;
this.initMermaid();
this.md = this.createMarkdownIt();
this.configureLinkRenderer();
this.render();
if (options.initialContent) {
this.setContent(options.initialContent);
} else {
this.setContent(DEFAULT_MARKDOWN);
}
}
private initMermaid(): void {
if (!this.mermaidInitialized) {
mermaid.initialize({
startOnLoad: false,
theme: 'default',
securityLevel: 'loose',
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif'
});
this.mermaidInitialized = true;
}
}
private configureLinkRenderer(): void {
// Override link renderer to add target="_blank" and rel="noopener"
const defaultRender = this.md.renderer.rules.link_open ||
((tokens: any[], idx: number, options: any, _env: any, self: any) => self.renderToken(tokens, idx, options));
this.md.renderer.rules.link_open = (tokens: any[], idx: number, options: any, env: any, self: any) => {
const token = tokens[idx];
token.attrSet('target', '_blank');
token.attrSet('rel', 'noopener noreferrer');
return defaultRender(tokens, idx, options, env, self);
};
}
private render(): void {
this.container.innerHTML = `
`;
this.editor = document.getElementById('mdTextarea') as HTMLTextAreaElement;
this.preview = document.getElementById('mdPreview') as HTMLElement;
this.setupEventListeners();
this.applyI18n();
// Initialize Lucide icons
if (typeof (window as any).lucide !== 'undefined') {
(window as any).lucide.createIcons();
}
}
private setupEventListeners(): void {
// Editor input
this.editor?.addEventListener('input', () => {
this.updatePreview();
});
// Sync scroll
const syncScrollBtn = document.getElementById('mdSyncScroll');
syncScrollBtn?.addEventListener('click', () => {
this.syncScroll = !this.syncScroll;
syncScrollBtn.classList.toggle('md-editor-btn-primary');
syncScrollBtn.classList.toggle('md-editor-btn-secondary');
});
// Editor scroll sync
this.editor?.addEventListener('scroll', () => {
if (this.syncScroll && !this.isSyncing && this.editor && this.preview) {
this.isSyncing = true;
const scrollPercentage = this.editor.scrollTop / (this.editor.scrollHeight - this.editor.clientHeight);
this.preview.scrollTop = scrollPercentage * (this.preview.scrollHeight - this.preview.clientHeight);
setTimeout(() => this.isSyncing = false, 10);
}
});
// Preview scroll sync (bidirectional)
this.preview?.addEventListener('scroll', () => {
if (this.syncScroll && !this.isSyncing && this.editor && this.preview) {
this.isSyncing = true;
const scrollPercentage = this.preview.scrollTop / (this.preview.scrollHeight - this.preview.clientHeight);
this.editor.scrollTop = scrollPercentage * (this.editor.scrollHeight - this.editor.clientHeight);
setTimeout(() => this.isSyncing = false, 10);
}
});
// Theme toggle
const themeToggle = document.getElementById('themeToggle');
const editorContainer = document.querySelector('.md-editor');
themeToggle?.addEventListener('click', () => {
editorContainer?.classList.toggle('light-mode');
themeToggle.classList.toggle('active');
});
// Settings modal open
document.getElementById('mdSettings')?.addEventListener('click', () => {
const modal = document.getElementById('mdSettingsModal');
if (modal) {
modal.style.display = 'flex';
}
});
// Settings modal close
document.getElementById('mdCloseSettings')?.addEventListener('click', () => {
const modal = document.getElementById('mdSettingsModal');
if (modal) {
modal.style.display = 'none';
}
});
// Close modal on overlay click
document.getElementById('mdSettingsModal')?.addEventListener('click', (e) => {
if ((e.target as HTMLElement).classList.contains('md-editor-modal-overlay')) {
const modal = document.getElementById('mdSettingsModal');
if (modal) {
modal.style.display = 'none';
}
}
});
// Settings checkboxes
document.getElementById('mdOptHtml')?.addEventListener('change', (e) => {
this.mdOptions.html = (e.target as HTMLInputElement).checked;
this.updateMarkdownIt();
});
document.getElementById('mdOptBreaks')?.addEventListener('change', (e) => {
this.mdOptions.breaks = (e.target as HTMLInputElement).checked;
this.updateMarkdownIt();
});
document.getElementById('mdOptLinkify')?.addEventListener('change', (e) => {
this.mdOptions.linkify = (e.target as HTMLInputElement).checked;
this.updateMarkdownIt();
});
document.getElementById('mdOptTypographer')?.addEventListener('change', (e) => {
this.mdOptions.typographer = (e.target as HTMLInputElement).checked;
this.updateMarkdownIt();
});
// Preset selector
document.getElementById('mdPreset')?.addEventListener('change', (e) => {
const preset = (e.target as HTMLSelectElement).value;
this.applyPreset(preset as 'default' | 'commonmark' | 'zero');
});
// Upload button
document.getElementById('mdUpload')?.addEventListener('click', () => {
document.getElementById('mdFileInput')?.click();
});
// File input change
document.getElementById('mdFileInput')?.addEventListener('change', (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (file) {
this.loadFile(file);
}
});
// Export PDF
document.getElementById('mdExport')?.addEventListener('click', () => {
this.exportPdf();
});
// Keyboard shortcuts
this.editor?.addEventListener('keydown', (e) => {
// Ctrl/Cmd + S to export
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
this.exportPdf();
}
// Tab key for indentation
if (e.key === 'Tab') {
e.preventDefault();
const start = this.editor!.selectionStart;
const end = this.editor!.selectionEnd;
const value = this.editor!.value;
this.editor!.value = value.substring(0, start) + ' ' + value.substring(end);
this.editor!.selectionStart = this.editor!.selectionEnd = start + 2;
this.updatePreview();
}
});
}
private currentPreset: 'default' | 'commonmark' | 'zero' = 'default';
private applyPreset(preset: 'default' | 'commonmark' | 'zero'): void {
this.currentPreset = preset;
// Update options based on preset
if (preset === 'commonmark') {
this.mdOptions = { html: false, breaks: false, linkify: false, typographer: false };
} else if (preset === 'zero') {
this.mdOptions = { html: false, breaks: false, linkify: false, typographer: false };
} else {
this.mdOptions = { html: true, breaks: false, linkify: true, typographer: true };
}
// Update UI checkboxes
(document.getElementById('mdOptHtml') as HTMLInputElement).checked = this.mdOptions.html;
(document.getElementById('mdOptBreaks') as HTMLInputElement).checked = this.mdOptions.breaks;
(document.getElementById('mdOptLinkify') as HTMLInputElement).checked = this.mdOptions.linkify;
(document.getElementById('mdOptTypographer') as HTMLInputElement).checked = this.mdOptions.typographer;
this.updateMarkdownIt();
}
private async loadFile(file: File): Promise {
try {
const text = await file.text();
this.setContent(text);
} catch (error) {
console.error('Failed to load file:', error);
}
}
private createMarkdownIt(): MarkdownIt {
// Use preset if commonmark or zero
let md: MarkdownIt;
if (this.currentPreset === 'commonmark') {
md = new MarkdownIt('commonmark');
} else if (this.currentPreset === 'zero') {
md = new MarkdownIt('zero');
// Enable basic features for zero preset
md.enable(['paragraph', 'newline', 'text']);
} else {
md = new MarkdownIt({
...this.mdOptions,
highlight: (str: string, lang: string) => {
if (lang && hljs.getLanguage(lang)) {
try {
return hljs.highlight(str, { language: lang, ignoreIllegals: true }).value;
} catch {
// Fall through to default
}
}
return ''; // Use external default escaping
}
});
}
// Apply plugins only for default preset (plugins may not work well with commonmark/zero)
if (this.currentPreset === 'default') {
md.use(sub) // Subscript: ~text~ -> text
.use(sup) // Superscript: ^text^ -> text
.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++ -> text
.use(mark) // Marked text: ==text== -> text
.use(taskLists, { enabled: true, label: true, labelAfter: true }) // Task lists: - [x] done
.use(anchor, { permalink: false }) // Header anchors
.use(tocDoneRight); // Table of contents: ${toc}
}
return md;
}
private updateMarkdownIt(): void {
this.md = this.createMarkdownIt();
this.configureLinkRenderer();
this.updatePreview();
}
private updatePreview(): void {
if (!this.editor || !this.preview) return;
const markdown = this.editor.value;
const html = this.md.render(markdown);
this.preview.innerHTML = html;
this.renderMermaidDiagrams();
}
private async renderMermaidDiagrams(): Promise {
if (!this.preview) return;
const mermaidBlocks = this.preview.querySelectorAll('pre > code.language-mermaid');
for (let i = 0; i < mermaidBlocks.length; i++) {
const block = mermaidBlocks[i] as HTMLElement;
const code = block.textContent || '';
const pre = block.parentElement;
if (pre && code.trim()) {
try {
const id = `mermaid-diagram-${i}-${Date.now()}`;
const { svg } = await mermaid.render(id, code.trim());
const wrapper = document.createElement('div');
wrapper.className = 'mermaid-diagram';
wrapper.innerHTML = svg;
pre.replaceWith(wrapper);
} catch (error) {
console.error('Mermaid rendering error:', error);
const errorDiv = document.createElement('div');
errorDiv.className = 'mermaid-error';
errorDiv.textContent = `Mermaid Error: ${(error as Error).message}`;
pre.replaceWith(errorDiv);
}
}
}
}
public setContent(content: string): void {
if (this.editor) {
this.editor.value = content;
this.updatePreview();
}
}
public getContent(): string {
return this.editor?.value || '';
}
public getHtml(): string {
return this.md.render(this.getContent());
}
private exportPdf(): void {
// Use browser's native print functionality
window.print();
}
private getStyledHtml(): string {
const content = this.getHtml();
return `
${content}
`;
}
private applyI18n(): void {
// Apply translations to elements within this component
applyTranslations();
// Special handling for select options (data-i18n on options doesn't work with applyTranslations)
const presetSelect = document.getElementById('mdPreset') as HTMLSelectElement;
if (presetSelect) {
const options = presetSelect.querySelectorAll('option[data-i18n]');
options.forEach((option) => {
const key = option.getAttribute('data-i18n');
if (key) {
// Use i18next directly for option text
const translated = (window as any).i18next?.t(key);
if (translated && translated !== key) {
option.textContent = translated;
}
}
});
}
}
public destroy(): void {
this.container.innerHTML = '';
}
}