Files
AIEC-new/AIEC-server/js/markdown-renderer.js
2025-10-17 09:31:28 +08:00

418 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Markdown渲染器 - 处理消息的Markdown格式化显示
*/
class MarkdownRenderer {
constructor() {
this.isMarkdownMode = true; // 默认开启Markdown模式
this.codeBlockId = 0; // 用于生成唯一的代码块ID
this.init();
}
init() {
// 加载用户偏好设置
const savedMode = localStorage.getItem('yundage_markdown_mode');
if (savedMode !== null) {
this.isMarkdownMode = savedMode === 'true';
}
}
/**
* 切换Markdown模式
*/
toggleMarkdownMode() {
this.isMarkdownMode = !this.isMarkdownMode;
localStorage.setItem('yundage_markdown_mode', this.isMarkdownMode);
return this.isMarkdownMode;
}
/**
* 渲染Markdown内容
* @param {string} content - 原始Markdown内容
* @param {object} options - 渲染选项
* @returns {string} HTML内容
*/
render(content, options = {}) {
if (!this.isMarkdownMode) {
// 纯文本模式,只转换换行
return this.escapeHtml(content).replace(/\n/g, '<br>');
}
// 先强制替换所有数字序列为符号(有序列表)
content = content.replace(/^\d+\.\s+/gm, '▶ ');
// Markdown模式渲染
let html = content;
// 1. 处理代码块(```语言\n代码\n```
html = this.renderCodeBlocks(html);
// 2. 处理行内代码(`代码`
html = this.renderInlineCode(html);
// 3. 处理标题(# ## ### 等)
html = this.renderHeaders(html);
// 4. 处理粗体(**文字** 或 __文字__
html = this.renderBold(html);
// 5. 处理斜体(*文字* 或 _文字_
html = this.renderItalic(html);
// 6. 处理链接([文字](链接)
html = this.renderLinks(html);
// 7. 处理图片(![alt](src)
html = this.renderImages(html);
// 8. 处理列表
html = this.renderLists(html);
// 9. 处理引用(> 引用内容)
html = this.renderBlockquotes(html);
// 10. 处理表格
html = this.renderTables(html);
// 11. 处理分隔线(--- 或 ***
html = this.renderHorizontalRules(html);
// 12. 处理换行
html = this.renderLineBreaks(html);
// 13. 最后强制处理所有数字序列,替换为符号
html = html.replace(/^\d+\.\s+/gm, '▸ ');
return html;
}
/**
* 转义HTML特殊字符
*/
escapeHtml(text) {
const map = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
};
return text.replace(/[&<>"']/g, m => map[m]);
}
/**
* 渲染代码块
*/
renderCodeBlocks(text) {
const codeBlockRegex = /```(\w+)?\n([\s\S]*?)```/g;
return text.replace(codeBlockRegex, (match, language, code) => {
const id = `code-block-${this.codeBlockId++}`;
const escapedCode = this.escapeHtml(code.trim());
const lang = language || 'plaintext';
return `
<div class="code-block-wrapper mb-4">
<div class="code-header flex justify-between items-center bg-gray-700 text-gray-200 px-3 py-1 rounded-t text-sm">
<span class="code-language">${lang}</span>
<button class="copy-code-btn hover:bg-gray-600 px-2 py-1 rounded transition-colors" data-code-id="${id}">
<i class="fas fa-copy"></i> 复制
</button>
</div>
<pre class="code-block bg-gray-800 text-gray-100 p-4 rounded-b overflow-x-auto" id="${id}"><code class="language-${lang}">${escapedCode}</code></pre>
</div>
`;
});
}
/**
* 渲染行内代码
*/
renderInlineCode(text) {
return text.replace(/`([^`]+)`/g, '<code class="inline-code bg-gray-200 text-red-600 px-1 py-0.5 rounded text-sm">$1</code>');
}
/**
* 渲染标题
*/
renderHeaders(text) {
// H6 到 H1从小到大避免误匹配
for (let i = 6; i >= 1; i--) {
const regex = new RegExp(`^${'#'.repeat(i)}\\s+(.+)$`, 'gm');
text = text.replace(regex, `<h${i} class="text-${this.getHeaderSize(i)} font-bold mb-3 mt-4">$1</h${i}>`);
}
return text;
}
getHeaderSize(level) {
const sizes = ['3xl', '2xl', 'xl', 'lg', 'base', 'sm'];
return sizes[level - 1] || 'base';
}
/**
* 渲染粗体
*/
renderBold(text) {
return text
.replace(/\*\*([^*]+)\*\*/g, '<strong class="font-bold">$1</strong>')
.replace(/__([^_]+)__/g, '<strong class="font-bold">$1</strong>');
}
/**
* 渲染斜体
*/
renderItalic(text) {
return text
.replace(/\*([^*]+)\*/g, '<em class="italic">$1</em>')
.replace(/_([^_]+)_/g, '<em class="italic">$1</em>');
}
/**
* 渲染链接
*/
renderLinks(text) {
return text.replace(/\[([^\]]+)\]\(([^)]+)\)/g,
'<a href="$2" target="_blank" class="text-blue-600 hover:underline">$1</a>');
}
/**
* 渲染图片
*/
renderImages(text) {
return text.replace(/!\[([^\]]*)\]\(([^)]+)\)/g,
'<img src="$2" alt="$1" class="max-w-full rounded shadow-md my-2">');
}
/**
* 渲染列表
*/
renderLists(text) {
// 简化处理直接将所有列表项包装为HTML不添加额外符号
// 无序列表
text = text.replace(/^[\*\-\+]\s+(.+)$/gm, '<li class="ml-4">$1</li>');
// 有序列表(已经在前面被替换为▶符号了)
text = text.replace(/^▶\s+(.+)$/gm, '<li class="ml-4">$1</li>');
// 统一包装所有列表项
text = text.replace(/(<li.*<\/li>\n?)+/g, match => {
return `<ul class="list-none mb-3">${match}</ul>`;
});
return text;
}
/**
* 渲染引用
*/
renderBlockquotes(text) {
return text.replace(/^>\s+(.+)$/gm,
'<blockquote class="border-l-4 border-gray-300 pl-4 py-2 my-2 text-gray-600">$1</blockquote>');
}
/**
* 渲染表格
*/
renderTables(text) {
const tableRegex = /\|(.+)\|\n\|[\s\-\|]+\|\n((?:\|.+\|\n?)+)/g;
return text.replace(tableRegex, (match, header, body) => {
const headers = header.split('|').filter(h => h.trim());
const rows = body.trim().split('\n').map(row =>
row.split('|').filter(cell => cell.trim())
);
let tableHtml = '<table class="min-w-full border-collapse border border-gray-300 my-4">';
tableHtml += '<thead><tr class="bg-gray-100">';
headers.forEach(h => {
tableHtml += `<th class="border border-gray-300 px-4 py-2 text-left">${h.trim()}</th>`;
});
tableHtml += '</tr></thead><tbody>';
rows.forEach(row => {
tableHtml += '<tr class="hover:bg-gray-50">';
row.forEach(cell => {
tableHtml += `<td class="border border-gray-300 px-4 py-2">${cell.trim()}</td>`;
});
tableHtml += '</tr>';
});
tableHtml += '</tbody></table>';
return tableHtml;
});
}
/**
* 渲染分隔线
*/
renderHorizontalRules(text) {
return text.replace(/^[\-\*]{3,}$/gm, '<hr class="my-4 border-gray-300">');
}
/**
* 渲染换行
*/
renderLineBreaks(text) {
return text.replace(/\n/g, '<br>');
}
/**
* 为代码块添加复制功能
*/
initCodeCopyButtons() {
document.addEventListener('click', (e) => {
if (e.target.closest('.copy-code-btn')) {
const btn = e.target.closest('.copy-code-btn');
const codeId = btn.dataset.codeId;
const codeBlock = document.getElementById(codeId);
if (codeBlock) {
const code = codeBlock.textContent;
navigator.clipboard.writeText(code).then(() => {
const originalHtml = btn.innerHTML;
btn.innerHTML = '<i class="fas fa-check"></i> 已复制';
setTimeout(() => {
btn.innerHTML = originalHtml;
}, 2000);
});
}
}
});
}
/**
* 创建Markdown工具栏
*/
createToolbar() {
return `
<button class="toolbar-btn" data-action="bold" title="粗体">
<i class="fas fa-bold"></i>
</button>
<button class="toolbar-btn" data-action="italic" title="斜体">
<i class="fas fa-italic"></i>
</button>
<button class="toolbar-btn" data-action="code" title="代码">
<i class="fas fa-code"></i>
</button>
<button class="toolbar-btn" data-action="link" title="链接">
<i class="fas fa-link"></i>
</button>
<button class="toolbar-btn" data-action="list" title="列表">
<i class="fas fa-list"></i>
</button>
<button class="toolbar-btn" data-action="quote" title="引用">
<i class="fas fa-quote-right"></i>
</button>
<button class="toolbar-btn toggle-markdown-btn" data-action="toggle-markdown" title="切换Markdown模式" style="margin-left: auto;">
<i class="fas fa-markdown"></i>
<span class="text-xs" style="margin-left: 0.25rem;">${this.isMarkdownMode ? 'Markdown: ON' : 'Markdown: OFF'}</span>
</button>
`;
}
/**
* 处理工具栏操作
*/
handleToolbarAction(action, textarea) {
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const text = textarea.value;
const selectedText = text.substring(start, end);
let replacement = '';
let cursorOffset = 0;
switch (action) {
case 'bold':
replacement = `**${selectedText || '粗体文字'}**`;
cursorOffset = selectedText ? replacement.length : 2;
break;
case 'italic':
replacement = `*${selectedText || '斜体文字'}*`;
cursorOffset = selectedText ? replacement.length : 1;
break;
case 'code':
if (selectedText.includes('\n')) {
replacement = `\`\`\`\n${selectedText}\n\`\`\``;
cursorOffset = 4;
} else {
replacement = `\`${selectedText || '代码'}\``;
cursorOffset = selectedText ? replacement.length : 1;
}
break;
case 'link':
replacement = `[${selectedText || '链接文字'}](URL)`;
cursorOffset = selectedText ? replacement.length - 4 : 1;
break;
case 'list':
replacement = `- ${selectedText || '列表项'}`;
cursorOffset = 2;
break;
case 'quote':
replacement = `> ${selectedText || '引用文字'}`;
cursorOffset = 2;
break;
}
textarea.value = text.substring(0, start) + replacement + text.substring(end);
textarea.selectionStart = start + cursorOffset;
textarea.selectionEnd = start + cursorOffset;
textarea.focus();
}
// 新增:为工具栏绑定事件
attachToolbarEvents(toolbar, textarea) {
toolbar.addEventListener('click', (e) => {
const button = e.target.closest('button[data-action]');
if (button) {
const action = button.dataset.action;
if (action === 'toggle-markdown') {
this.toggleMarkdownMode();
button.textContent = this.isMarkdownMode ? 'Markdown: ON' : 'Markdown: OFF';
} else {
this.insertMarkdown(textarea, action);
}
}
});
}
}
// 创建全局实例
window.markdownRenderer = new MarkdownRenderer();
// 初始化工具栏函数
function initMarkdownToolbars() {
console.log('初始化Markdown工具栏...');
// 为欢迎模式输入框创建工具栏
const welcomeToolbar = document.getElementById('markdownToolbar');
const welcomeInput = document.getElementById('messageInput');
if (welcomeToolbar && welcomeInput) {
console.log('创建欢迎模式工具栏');
welcomeToolbar.innerHTML = window.markdownRenderer.createToolbar();
welcomeToolbar.style.display = 'flex';
window.markdownRenderer.attachToolbarEvents(welcomeToolbar, welcomeInput);
}
// 为聊天模式输入框创建工具栏
const chatToolbar = document.getElementById('chatModeMarkdownToolbar');
const chatInput = document.getElementById('chatModeMessageInput');
if (chatToolbar && chatInput) {
console.log('创建聊天模式工具栏');
chatToolbar.innerHTML = window.markdownRenderer.createToolbar();
chatToolbar.style.display = 'flex';
window.markdownRenderer.attachToolbarEvents(chatToolbar, chatInput);
}
}
// 初始化工具栏
document.addEventListener('DOMContentLoaded', function() {
initMarkdownToolbars();
});
// 确保在所有资源加载完成后也初始化一次
window.addEventListener('load', function() {
setTimeout(initMarkdownToolbars, 100);
});
// 导出初始化函数供其他模块使用
window.initMarkdownToolbars = initMarkdownToolbars;