418 lines
14 KiB
JavaScript
418 lines
14 KiB
JavaScript
|
|
/**
|
|||
|
|
* 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. 处理图片()
|
|||
|
|
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 = {
|
|||
|
|
'&': '&',
|
|||
|
|
'<': '<',
|
|||
|
|
'>': '>',
|
|||
|
|
'"': '"',
|
|||
|
|
"'": '''
|
|||
|
|
};
|
|||
|
|
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;
|