mirror of
https://github.com/kuaifan/dootask.git
synced 2025-12-12 19:35:50 +00:00
370 lines
13 KiB
JavaScript
Vendored
370 lines
13 KiB
JavaScript
Vendored
import MarkdownIt from "markdown-it";
|
||
import hljs from "highlight.js";
|
||
import mila from "markdown-it-link-attributes";
|
||
import mdKatex from "@traptitech/markdown-it-katex";
|
||
|
||
/**
|
||
* Markdown
|
||
*/
|
||
const MarkdownUtils = {
|
||
mdi: null,
|
||
mds: null,
|
||
|
||
/**
|
||
* 解析Markdown
|
||
* @param {*} text
|
||
* @returns
|
||
*/
|
||
formatMsg: (text) => {
|
||
// 如果存在body标签,只取body中的内容
|
||
const bodyMatch = text.match(/<body[^>]*>([\s\S]*?)<\/body>/i);
|
||
if (bodyMatch) {
|
||
text = bodyMatch[1];
|
||
}
|
||
|
||
// 使用正则一次性替换所有的link、script、style标签
|
||
text = text.replace(/<(link|script|style)[^>]*>[\s\S]*?<\/\1>|<(link|script|style)[^>]*\/?>/gi, '');
|
||
|
||
// 处理图片标签
|
||
const imgRegex = /<img\s+[^>]*?>/g;
|
||
const imgArray = text.match(imgRegex);
|
||
if (imgArray) {
|
||
// 创建一个替换映射,避免多次字符串替换操作
|
||
const replacements = {};
|
||
imgArray.forEach(img => {
|
||
replacements[img] = `<div class="no-size-image-box">${img}</div>`;
|
||
});
|
||
|
||
// 一次性完成所有替换
|
||
for (const [original, replacement] of Object.entries(replacements)) {
|
||
text = text.replace(original, replacement);
|
||
}
|
||
}
|
||
|
||
// 处理a标签,确保所有链接在新窗口打开
|
||
text = text.replace(/<a\s+([^>]*)>/gi, (match, attributes) => {
|
||
// 如果已经有target属性,检查是否为_blank
|
||
if (attributes.includes('target=')) {
|
||
// 将已有的target属性替换为target="_blank"
|
||
return match.replace(/target=(['"])[^'"]*\1/i, 'target="_blank"');
|
||
} else {
|
||
// 如果没有target属性,添加target="_blank"和rel="noopener noreferrer"
|
||
return `<a ${attributes} target="_blank" rel="noopener noreferrer">`;
|
||
}
|
||
});
|
||
|
||
return text;
|
||
},
|
||
|
||
/**
|
||
* 高亮代码块
|
||
* @param {*} str
|
||
* @param {*} lang
|
||
* @returns
|
||
*/
|
||
highlightBlock: (str, lang = '') => {
|
||
return `<pre class="code-block-wrapper"><div class="code-block-header"><span class="code-block-header__lang">${lang}</span><span class="code-block-header__copy">${$A.L('复制')}</span></div><code class="hljs code-block-body ${lang}">${str}</code></pre>`
|
||
},
|
||
}
|
||
|
||
const MarkdownPluginUtils = {
|
||
// 配置选项
|
||
config: {
|
||
maxItems: 200,
|
||
maxTitleLength: 200,
|
||
maxDescLength: 1000,
|
||
buttonLabels: {
|
||
task: '创建任务',
|
||
subtask: '创建子任务'
|
||
}
|
||
},
|
||
|
||
// HTML转义函数
|
||
escapeHtml(unsafe) {
|
||
return unsafe
|
||
.replace(/&/g, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """)
|
||
.replace(/'/g, "'");
|
||
},
|
||
|
||
// 验证输入
|
||
validateInput(value, maxLength) {
|
||
if (!value) return '';
|
||
if (typeof value !== 'string') return '';
|
||
if (value.length > maxLength) {
|
||
return value.substring(0, maxLength) + '...';
|
||
}
|
||
return value;
|
||
},
|
||
|
||
// 清除空推理
|
||
clearEmptyReasoning(text) {
|
||
return text.replace(/:::\s*reasoning\s*[\r\n]*\s*:::/g, '');
|
||
},
|
||
|
||
// 修改初始化插件函数(推理)
|
||
initReasoningPlugin(md) {
|
||
md.block.ruler.before('fence', 'reasoning', (state, startLine, endLine, silent) => {
|
||
const start = state.bMarks[startLine] + state.tShift[startLine];
|
||
const max = state.eMarks[startLine];
|
||
const firstLine = state.src.slice(start, max).trim();
|
||
|
||
// 检查是否匹配 :::reasoning 开始标记
|
||
const match = firstLine.match(/^:::\s*reasoning(?:\s+(\S+))?$/);
|
||
if (!match) {
|
||
return false;
|
||
}
|
||
|
||
if (silent) {
|
||
return true;
|
||
}
|
||
|
||
let nextLine = startLine + 1;
|
||
let content = [];
|
||
|
||
// 查找结束标记 :::
|
||
while (nextLine < endLine) {
|
||
const lineStart = state.bMarks[nextLine] + state.tShift[nextLine];
|
||
const lineMax = state.eMarks[nextLine];
|
||
const currentLine = state.src.slice(lineStart, lineMax);
|
||
|
||
if (currentLine.trim() === ':::') {
|
||
break;
|
||
}
|
||
|
||
content.push(state.getLines(nextLine, nextLine + 1, state.tShift[nextLine], true));
|
||
nextLine++;
|
||
}
|
||
|
||
// 创建外层容器
|
||
let token = state.push('reasoning_open', 'div', 1);
|
||
token.attrs = [['class', 'apply-reasoning']];
|
||
|
||
// 创建标签
|
||
token = state.push('reasoning_label_open', 'div', 1);
|
||
token.attrs = [['class', 'reasoning-label']];
|
||
token = state.push('text', '', 0);
|
||
token.content = $A.L('思考过程');
|
||
state.push('reasoning_label_close', 'div', -1);
|
||
|
||
// 创建内容容器
|
||
token = state.push('reasoning_content_open', 'div', 1);
|
||
token.attrs = [['class', 'reasoning-content']];
|
||
|
||
// 处理内容
|
||
if (content.length > 0) {
|
||
state.md.block.parse(content.join('\n'), state.md, state.env, state.tokens);
|
||
}
|
||
|
||
// 关闭内容容器
|
||
state.push('reasoning_content_close', 'div', -1);
|
||
|
||
// 关闭外层容器
|
||
state.push('reasoning_close', 'div', -1);
|
||
|
||
state.line = nextLine + 1;
|
||
return true;
|
||
});
|
||
},
|
||
|
||
// 修改初始化插件函数(创建任务)
|
||
initCreateTaskPlugin(md) {
|
||
md.block.ruler.before('fence', 'create-task', (state, startLine, endLine, silent) => {
|
||
const start = state.bMarks[startLine] + state.tShift[startLine];
|
||
const max = state.eMarks[startLine];
|
||
const firstLine = state.src.slice(start, max).trim();
|
||
|
||
// 检查开始标记,并获取status值
|
||
const match = firstLine.match(/^:::\s*(create-task-list|create-subtask-list)(?:\s+(\S+))?$/);
|
||
if (!match) {
|
||
return false;
|
||
}
|
||
|
||
if (silent) {
|
||
return true;
|
||
}
|
||
|
||
// 获取按钮标题和状态
|
||
const listType = match[1] === 'create-task-list' ? 'task' : 'subtask';
|
||
const buttonTitle = this.config.buttonLabels[listType] || '';
|
||
const status = match[2] || '';
|
||
|
||
let nextLine = startLine + 1;
|
||
let content = [];
|
||
|
||
// 查找结束标记
|
||
while (nextLine < endLine) {
|
||
const lineStart = state.bMarks[nextLine] + state.tShift[nextLine];
|
||
const lineMax = state.eMarks[nextLine];
|
||
const line = state.src.slice(lineStart, lineMax);
|
||
|
||
if (line.trim() === ':::') {
|
||
break;
|
||
}
|
||
|
||
content.push(line);
|
||
nextLine++;
|
||
}
|
||
|
||
// 解析任务
|
||
const tasks = [];
|
||
let currentTask = null;
|
||
let isCollectingDesc = false;
|
||
let descLines = [];
|
||
|
||
content.forEach(line => {
|
||
const titleMatch = line.trim().match(/^title:\s*(.+)$/);
|
||
const descMatch = line.trim().match(/^desc:\s*(.*)$/);
|
||
|
||
if (titleMatch) {
|
||
// 如果已经有一个任务在处理中,保存它
|
||
if (currentTask) {
|
||
if (descLines.length > 0) {
|
||
currentTask.desc = descLines.join('\n');
|
||
}
|
||
tasks.push(currentTask);
|
||
}
|
||
|
||
// 开始新的任务
|
||
currentTask = {title: titleMatch[1]};
|
||
isCollectingDesc = false;
|
||
descLines = [];
|
||
} else if (descMatch) {
|
||
isCollectingDesc = true;
|
||
if (descMatch[1]) {
|
||
descLines.push(descMatch[1]);
|
||
}
|
||
} else if (isCollectingDesc && line.trim() && !line.trim().startsWith('title:')) {
|
||
// 收集多行描述,但不包括空行和新的title行
|
||
descLines.push(line.trim());
|
||
}
|
||
});
|
||
|
||
// 处理最后一个任务
|
||
if (currentTask) {
|
||
if (descLines.length > 0) {
|
||
currentTask.desc = descLines.join('\n');
|
||
}
|
||
tasks.push(currentTask);
|
||
}
|
||
|
||
// 生成HTML
|
||
const showIndex = tasks.length > 1;
|
||
const taskItems = tasks.slice(0, this.config.maxItems).map((task, index) => [
|
||
'<li>',
|
||
showIndex ? `<div class="task-index">${index + 1}.</div>` : '',
|
||
'<div class="task-item">',
|
||
`<div class="title">${this.escapeHtml(this.validateInput(task.title, this.config.maxTitleLength))}</div>`,
|
||
task.desc && match[1] === 'create-task-list' ? `<div class="desc">${this.escapeHtml(this.validateInput(task.desc, this.config.maxDescLength))}</div>` : '',
|
||
'</div>',
|
||
'</li>'
|
||
].join(''));
|
||
|
||
const htmls = [
|
||
'<div class="apply-create-task">',
|
||
'<ul>',
|
||
taskItems.join(''),
|
||
'</ul>',
|
||
'<div class="apply-button">',
|
||
`<div class="apply-create-${listType}-button${status ? ' ' + status : ''}">${$A.L(buttonTitle)}</div>`,
|
||
'</div>',
|
||
'</div>'
|
||
];
|
||
|
||
// 添加token
|
||
const token = state.push('html_block', '', 0);
|
||
token.content = htmls.join('');
|
||
token.map = [startLine, nextLine];
|
||
|
||
state.line = nextLine + 1;
|
||
return true;
|
||
})
|
||
}
|
||
};
|
||
|
||
export {MarkdownPluginUtils}
|
||
|
||
export function MarkdownConver(text) {
|
||
if (text === '...') {
|
||
return '<div class="input-blink"></div>'
|
||
}
|
||
if (MarkdownUtils.mdi === null) {
|
||
MarkdownUtils.mdi = new MarkdownIt({
|
||
html: true,
|
||
breaks: true,
|
||
linkify: false,
|
||
typographer: true,
|
||
highlight(code, language) {
|
||
const validLang = !!(language && hljs.getLanguage(language))
|
||
if (validLang) {
|
||
const lang = language ?? ''
|
||
return MarkdownUtils.highlightBlock(hljs.highlight(code, {language: lang}).value, lang)
|
||
}
|
||
return MarkdownUtils.highlightBlock(hljs.highlightAuto(code).value, '')
|
||
},
|
||
})
|
||
MarkdownUtils.mdi.use(mila, {attrs: {target: '_blank', rel: 'noopener noreferrer'}})
|
||
MarkdownUtils.mdi.use(mdKatex, {blockClass: 'katexmath-block rounded-md p-[10px]', errorColor: ' #cc0000'})
|
||
MarkdownPluginUtils.initReasoningPlugin(MarkdownUtils.mdi);
|
||
MarkdownPluginUtils.initCreateTaskPlugin(MarkdownUtils.mdi);
|
||
}
|
||
text = MarkdownPluginUtils.clearEmptyReasoning(text);
|
||
text = MarkdownUtils.mdi.render(text);
|
||
return MarkdownUtils.formatMsg(text)
|
||
}
|
||
|
||
export function MarkdownPreview(text) {
|
||
if (MarkdownUtils.mds === null) {
|
||
MarkdownUtils.mds = MarkdownIt()
|
||
MarkdownPluginUtils.initReasoningPlugin(MarkdownUtils.mds);
|
||
MarkdownPluginUtils.initCreateTaskPlugin(MarkdownUtils.mds);
|
||
}
|
||
text = MarkdownPluginUtils.clearEmptyReasoning(text);
|
||
return MarkdownUtils.mds.render(text)
|
||
}
|
||
|
||
export function isMarkdownFormat(html) {
|
||
if (html === '') {
|
||
return false
|
||
}
|
||
const tmp = html.replace(/<p>/g, '\n').replace(/(^|\s+)```([\s\S]*)```/gm, '')
|
||
if (/<\/(strong|s|em|u|ol|ul|li|blockquote|pre|img|a)>/i.test(tmp)) {
|
||
return false
|
||
}
|
||
if (/<span[^>]+?class="mention"[^>]*?>/i.test(tmp)) {
|
||
return false
|
||
}
|
||
//
|
||
const el = document.createElement('div')
|
||
el.style.position = 'fixed'
|
||
el.style.top = '0'
|
||
el.style.left = '0'
|
||
el.style.width = '10px'
|
||
el.style.height = '10px'
|
||
el.style.overflow = 'hidden'
|
||
el.style.zIndex = '-9999'
|
||
el.style.opacity = '0'
|
||
el.innerHTML = html
|
||
document.body.appendChild(el)
|
||
const text = el.innerText
|
||
document.body.removeChild(el)
|
||
//
|
||
if (
|
||
/(^|\s+)#+\s(.*)$/m.test(text) // 标题
|
||
|| /(^|\s+)\*\*(.*)\*\*/m.test(text) // 粗体
|
||
|| /(^|\s+)__(.*)__/m.test(text) // 粗体
|
||
|| /(^|\s+)\*(.*)\*/m.test(text) // 斜体
|
||
|| /(^|\s+)_(.*)_/m.test(text) // 斜体
|
||
|| /(^|\s+)~~(.*)~~/m.test(text) // 删除线
|
||
|| /(^|\s+)\[(.*?)\]\((.*?)\)/m.test(text) // 链接
|
||
|| /(^|\s+)!\[(.*?)\]\((.*?)\)/m.test(text) // 图片
|
||
|| /(^|\s+)`(.*?)`/m.test(text) // 行内代码
|
||
|| /(^|\s+)```([\s\S]*?)```/m.test(text) // 代码块
|
||
) {
|
||
return true
|
||
}
|
||
return false
|
||
}
|