diff --git a/packages/cherry-markdown/src/addons/cherry-code-block-mermaid-plugin.js b/packages/cherry-markdown/src/addons/cherry-code-block-mermaid-plugin.js index 16a692934..1773b4831 100644 --- a/packages/cherry-markdown/src/addons/cherry-code-block-mermaid-plugin.js +++ b/packages/cherry-markdown/src/addons/cherry-code-block-mermaid-plugin.js @@ -15,6 +15,7 @@ */ import mergeWith from 'lodash/mergeWith'; import { isBrowser } from '@/utils/env'; +import Logger from '@/Logger'; const CHART_TYPES = [ 'flowchart', @@ -85,7 +86,7 @@ export default class MermaidCodeEngine { * @param {Object} mermaidOptions - Mermaid 配置选项 * @param {Object} [mermaidOptions.mermaid] - mermaid 实例对象,如果未提供会尝试从 window.mermaid 获取 * @param {Object} [mermaidOptions.mermaidAPI] - mermaidAPI 实例对象,如果未提供会尝试从 window.mermaidAPI 获取 - * @param {string} [mermaidOptions.theme='default'] - 主题,可选值: 'default', 'dark', 'forest', 'neutral' 等 + * @param {string} [mermaidOptions.theme='default'] - 主题,可选值:'default', 'dark', 'forest', 'neutral' 等 * @param {string} [mermaidOptions.altFontFamily='sans-serif'] - 备用字体 * @param {string} [mermaidOptions.fontFamily='sans-serif'] - 主字体 * @param {string} [mermaidOptions.themeCSS] - 自定义主题 CSS 样式 @@ -151,7 +152,7 @@ export default class MermaidCodeEngine { } /** - * 转换svg为img,如果出错则直出svg + * 转换 svg 为 img,如果出错则直出 svg * @param {string} svgCode * @param {string} graphId * @returns {string} @@ -164,7 +165,7 @@ export default class MermaidCodeEngine { try { const svgDoc = /** @type {XMLDocument} */ (domParser.parseFromString(svgCode, 'image/svg+xml')); const svgDom = /** @type {SVGSVGElement} */ (/** @type {any} */ (svgDoc.documentElement)); - // tagName不是svg时,说明存在parse error + // tagName 不是 svg 时,说明存在 parse error if (svgDom.tagName.toLowerCase() === 'svg') { svgDom.style.maxWidth = '100%'; svgDom.style.height = 'auto'; @@ -180,7 +181,7 @@ export default class MermaidCodeEngine { svgDom.getAttribute('height') === '100%' && svgDom.setAttribute('height', `${svgBox.height}`); // fix end svgHtml = svgDoc.documentElement.outerHTML; - // 屏蔽转img标签功能,如需要转换为img解除屏蔽即可 + // 屏蔽转 img 标签功能,如需要转换为 img 解除屏蔽即可 if (this.svg2img) { const dataUrl = `data:image/svg+xml,${encodeURIComponent(svgDoc.documentElement.outerHTML)}`; svgHtml = `${graphId}`; @@ -219,8 +220,8 @@ export default class MermaidCodeEngine { /** * 如果开启了流式渲染,当前有上次渲染结果时,使用上次渲染结果 * 这里有赌的成分 - * 流式输出场景,只有最后一个mermaid代码块在流式输出,随着最后一个mermaid流式输出,mermaid的渲染有概率会失败 - * 这里赌的是只有一个mermaid代码块需要渲染 + * 流式输出场景,只有最后一个 mermaid 代码块在流式输出,随着最后一个 mermaid 流式输出,mermaid 的渲染有概率会失败 + * 这里赌的是只有一个 mermaid 代码块需要渲染 */ if ($engine.$cherry.options.engine.global.flowSessionContext && this.lastRenderedCode) { return this.lastRenderedCode; @@ -249,6 +250,7 @@ export default class MermaidCodeEngine { asyncRender(graphId, src, sign, $engine, props) { $engine.asyncRenderHandler.add(graphId); + this.mermaidAPIRefs .render(graphId, src, this.mermaidCanvas) .then(({ svg: svgCode }) => { @@ -260,10 +262,10 @@ export default class MermaidCodeEngine { .catch(() => { /** * 如果开启了流式渲染,当前有上次渲染结果时,使用上次渲染结果 - * 这里有赌的成分,流式输出场景,只有最后一个mermaid代码块在流式输出,随着最后一个mermaid流式输出,mermaid的渲染有概率会失败 + * 这里有赌的成分,流式输出场景,只有最后一个 mermaid 代码块在流式输出,随着最后一个 mermaid 流式输出,mermaid 的渲染有概率会失败 * 这里赌的是: - * 1、只有一个mermaid代码块需要渲染 - * 2、纯预览模式,且流式输出场景,所有mermaid都正常输出 + * 1、只有一个 mermaid 代码块需要渲染 + * 2、纯预览模式,且流式输出场景,所有 mermaid 都正常输出 */ if ( $engine.$cherry.options.engine.global.flowSessionContext && @@ -278,22 +280,31 @@ export default class MermaidCodeEngine { this.handleAsyncRenderDone(graphId, sign, $engine, props, html); } }); + Logger.log('Mermaid async render started:', { graphId, sign }); if (this.needReturnLastRenderedCode) { return this.lastRenderedCode; } - // 先渲染源码 + Logger.log('Mermaid async render done:', { graphId, sign }); + + // 【关键修改】在流式渲染模式下,不显示代码块,而是显示占位符 + if ($engine.$cherry.options.engine.global.flowSessionContext) { + return `
+
Mermaid 图表渲染中...
+
`; + } + + // 非流式模式下,先渲染源码 return props.fallback(); } - render(src, sign, $engine, props = {}) { let $sign = sign; if (!$sign) { $sign = Math.round(Math.random() * 100000000); } this.mountMermaidCanvas($engine); - // 多实例的情况下相同的内容ID相同会导致mermaid渲染异常 - // 需要通过添加时间戳使得多次渲染相同内容的图像ID唯一 - // 图像渲染节流在CodeBlock Hook内部控制 + // 多实例的情况下相同的内容 ID 相同会导致 mermaid 渲染异常 + // 需要通过添加时间戳使得多次渲染相同内容的图像 ID 唯一 + // 图像渲染节流在 CodeBlock Hook 内部控制 const graphId = `mermaid-${sign}-${new Date().getTime()}`; this.svg2img = props.mermaidConfig?.svg2img ?? false; return this.isAsyncRenderVersion() diff --git a/packages/cherry-markdown/src/core/hooks/CodeBlock.js b/packages/cherry-markdown/src/core/hooks/CodeBlock.js index ee6255577..f80b7a9c0 100644 --- a/packages/cherry-markdown/src/core/hooks/CodeBlock.js +++ b/packages/cherry-markdown/src/core/hooks/CodeBlock.js @@ -18,7 +18,7 @@ import Prism from 'prismjs'; import { escapeHTMLSpecialChar } from '@/utils/sanitize'; import { getTableRule, getCodeBlockRule } from '@/utils/regexp'; import { prependLineFeedForParagraph } from '@/utils/lineFeed'; - +import Logger from '@/Logger'; Prism.manual = true; const CUSTOM_WRAPPER = { @@ -41,8 +41,8 @@ export default class CodeBlock extends ParagraphBase { this.expandCode = config.expandCode; // 是否显示“展开”按钮 this.editCode = config.editCode; // 是否显示“编辑”按钮 this.changeLang = config.changeLang; // 是否显示“切换语言”按钮 - this.selfClosing = config.selfClosing; // 自动闭合,为true时,当md中有奇数个```时,会自动在md末尾追加一个``` - this.mermaid = config.mermaid; // mermaid的配置,目前仅支持格式设置,svg2img=true 展示成图片,false 展示成svg + this.selfClosing = config.selfClosing; // 自动闭合,为 true 时,当 md 中有奇数个```时,会自动在 md 末尾追加一个``` + this.mermaid = config.mermaid; // mermaid 的配置,目前仅支持格式设置,svg2img=true 展示成图片,false 展示成 svg this.indentedCodeBlock = typeof config.indentedCodeBlock === 'undefined' ? true : config.indentedCodeBlock; // 是否支持缩进代码块 this.INLINE_CODE_REGEX = /(`+)(.+?(?:\n.+?)*?)\1/g; if (config && config.customRenderer) { @@ -83,7 +83,7 @@ export default class CodeBlock extends ParagraphBase { $resetCache() { if (this.codeCacheList.length > 100) { - // 如果缓存超过100条,则清空最早的缓存 + // 如果缓存超过 100 条,则清空最早的缓存 for (let i = 0; i < this.codeCacheList.length - 100; i++) { delete this.codeCache[this.codeCacheList[i]]; } @@ -218,7 +218,7 @@ export default class CodeBlock extends ParagraphBase { } /** - * 补齐用codeBlock承载的mermaid + * 补齐用 codeBlock 承载的 mermaid * @param {string} $code * @param {string} $lang */ @@ -235,7 +235,7 @@ export default class CodeBlock extends ParagraphBase { lang = 'mermaid'; } if (lang === 'mermaid') { - // 8.4.8版本兼容8.5.2版本的语法 + // 8.4.8 版本兼容 8.5.2 版本的语法 code = code.replace(/(^[\s]*)stateDiagram-v2\n/, '$1stateDiagram\n'); // code = code.replace(/(^[\s]*)sequenceDiagram[ \t]*\n[\s]*autonumber[ \t]*\n/, '$1sequenceDiagram\n'); } @@ -271,33 +271,32 @@ export default class CodeBlock extends ParagraphBase { // 平台自定义代码块样式 cacheCode = this.customHighlighter(cacheCode, lang); } else { - // 默认使用prism渲染代码块 - if (!lang || !Prism.languages[lang]) lang = 'javascript'; // 如果没有写语言,默认用js样式渲染 + if (!lang || !Prism.languages[lang]) lang = 'javascript'; // 如果没有写语言,默认用 js 样式渲染 cacheCode = Prism.highlight(cacheCode, Prism.languages[lang], lang); cacheCode = this.renderLineNumber(cacheCode); } const needUnExpand = this.expandCode && $code.match(/\n/g)?.length > 10; // 是否需要收起代码块 const codeHtml = `
${this.wrapCode(cacheCode, lang)}
`; cacheCode = `
- ${this.customWrapperRender(oldLang, cacheCode, codeHtml)} - `; + data-sign="${sign}" + data-type="codeBlock" + data-lines="${lines}" + data-edit-code="${this.editCode}" + data-copy-code="${this.copyCode}" + data-expand-code="${this.expandCode}" + data-change-lang="${this.changeLang}" + data-lang="${lang}" + style="position:relative" + class="${needUnExpand ? 'cherry-code-unExpand' : 'cherry-code-expand'}" + > + ${this.customWrapperRender(oldLang, cacheCode, codeHtml)} + `; if (needUnExpand) { cacheCode += `
-
- -
-
`; +
+ +
+
`; } cacheCode += ''; return cacheCode; @@ -366,6 +365,7 @@ export default class CodeBlock extends ParagraphBase { }); } + //现在这里要做的只是把 mermaid 的排除逻辑放进来 ? r 然后其他的就不要管了,尽量不要影响其他功能 $dealUnclosingCode(str) { const codes = str.match( /(?:^|\n)(\n*((?:>[\t ]*)*)(?:[^\S\n]*))(`{3,})([^`]*?)(?=CHERRY_FLOW_SESSION_CURSOR|$|\n)/g, @@ -386,8 +386,11 @@ export default class CodeBlock extends ParagraphBase { codeBegin = false; return true; }); - // 如果有奇数个代码块关键字,则进行自动闭合 if ($codes.length % 2 === 1) { + // 在流式输出模式下,如果有未闭合的代码块,不输出任何内容 + if (this.$cherry.options.engine.global.flowSessionContext) { + return str; + } const lastCode = $codes[$codes.length - 1].replace(/(`)[^`]+$/, '$1').replace(/\n+/, ''); const $str = str.replace(/\n+$/, '').replace(/\n`{1,2}$/, ''); return `${$str}\n${lastCode}\n`; @@ -397,12 +400,10 @@ export default class CodeBlock extends ParagraphBase { beforeMakeHtml(str, sentenceMakeFunc, markdownParams) { let $str = str; - // 处理段落代码块自动闭合 if (this.selfClosing || this.$cherry.options.engine.global.flowSessionContext) { $str = this.$dealUnclosingCode($str); } - // 预处理缩进代码块 $str = this.$replaceCodeInIndent($str); @@ -418,7 +419,7 @@ export default class CodeBlock extends ParagraphBase { } let $code = code; const { sign, lines } = this.computeLines(match, leadingContent, code); - // 从缓存中获取html + // 从缓存中获取 html let cacheCode = this.$codeCache(sign); if (cacheCode && cacheCode !== '') { // 别忘了把 ">"(引用块)加回来 @@ -448,7 +449,7 @@ export default class CodeBlock extends ParagraphBase { // 如果是公式关键字,则直接返回 if (/^(math|katex|latex)$/i.test($lang) && !this.isInternalCustomLangCovered($lang)) { const prefix = match.match(/^\s*/g); - // ~D为经编辑器中间转义后的$,code结尾包含结束```前的所有换行符,所以不需要补换行 + // ~D 为经编辑器中间转义后的$,code 结尾包含结束```前的所有换行符,所以不需要补换行 return `${prefix}~D~D\n${$code}~D~D`; // 提供公式语法供公式钩子解析 } [$code, $lang] = this.appendMermaid($code, $lang); @@ -467,14 +468,14 @@ export default class CodeBlock extends ParagraphBase { this.$codeCache(sign, cacheCode); return this.getCacheWithSpace(this.pushCache(cacheCode, sign, lines), match); } - // 渲染出错则按正常code进行渲染 + // 渲染出错则按正常 code 进行渲染 } // $code = this.$replaceSpecialChar($code); cacheCode = this.$codeReplace($code, $lang, sign, lines); const result = this.getCacheWithSpace(this.pushCache(cacheCode, sign, lines), match); return addBlockQuoteSignToResult(result); }); - // 表格里处理行内代码,让一个td里的行内代码语法生效,让跨td的行内代码语法失效 + // 表格里处理行内代码,让一个 td 里的行内代码语法生效,让跨 td 的行内代码语法失效 $str = $str.replace(getTableRule(true), (whole, ...args) => { return whole .replace(/\\\|/g, '~CHERRYNormalLine') @@ -485,8 +486,8 @@ export default class CodeBlock extends ParagraphBase { .join('|') .replace(/`/g, '\\`'); }); - // 为了避免InlineCode被HtmlBlock转义,需要在这里提前缓存 - // InlineBlock只需要在afterMakeHtml还原即可 + // 为了避免 InlineCode 被 HtmlBlock 转义,需要在这里提前缓存 + // InlineBlock 只需要在 afterMakeHtml 还原即可 $str = this.makeInlineCode($str, true); // 处理缩进代码块 @@ -501,7 +502,7 @@ export default class CodeBlock extends ParagraphBase { * @returns {string} 格式化后的语言 */ formatLang(lang) { - // 增加一个潜规则,即便配置了all,也不处理mermaid + // 增加一个潜规则,即便配置了 all,也不处理 mermaid if (this.customLang.indexOf('all') !== -1 && lang !== 'mermaid') { return 'all'; } @@ -521,7 +522,7 @@ export default class CodeBlock extends ParagraphBase { $code = $code.replace(/~CHERRYNormalLine/g, '|'); $code = $code.replace(/\\/g, '\\\\'); - // 如果行内代码只有一个颜色值,则在code末尾追加一个颜色圆点 + // 如果行内代码只有一个颜色值,则在 code 末尾追加一个颜色圆点 const trimmed = $code.trim(); const isHex = /^#([0-9a-fA-F]{6})$/i.test(trimmed); const isRgb = /^rgb\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*\)$/i.test(trimmed); diff --git a/packages/cherry-markdown/src/core/hooks/Suggester.js b/packages/cherry-markdown/src/core/hooks/Suggester.js index 88ae09566..2e88dc560 100644 --- a/packages/cherry-markdown/src/core/hooks/Suggester.js +++ b/packages/cherry-markdown/src/core/hooks/Suggester.js @@ -26,6 +26,7 @@ import { Pass } from 'codemirror/src/util/misc'; import { isLookbehindSupported } from '@/utils/regexp'; import { replaceLookbehind } from '@/utils/lookbehind-replace'; import { isBrowser } from '@/utils/env'; +import Logger from '@/Logger'; /** * @typedef {import('codemirror')} CodeMirror @@ -58,7 +59,7 @@ export default class Suggester extends SyntaxBase { /** * config.suggester 内容 * [{ - * 请求url + * 请求 url suggestList: '', 唤醒关键字 keyword: '@', @@ -87,7 +88,7 @@ export default class Suggester extends SyntaxBase { } afterInit(callback) { - // node环境下直接跳过输入联想 + // node 环境下直接跳过输入联想 if (!isBrowser()) { return; } @@ -111,7 +112,7 @@ export default class Suggester extends SyntaxBase { defaultSuggest.push({ keyword: suggesterKeyword, suggestList(_word, callback) { - // 将word全转成小写 + // 将 word 全转成小写 const word = _word.toLowerCase(); const systemSuggestList = allSuggestList( suggesterKeyword, @@ -125,7 +126,7 @@ export default class Suggester extends SyntaxBase { } const keyword = word .replace(/\s+/g, '') // 删掉空格,避免产生不必要的空数组元素 - .replace(new RegExp(`^${suggesterKeyword}`, 'g'), '') // 删掉word当中suggesterKeywords出现的字符 + .replace(new RegExp(`^${suggesterKeyword}`, 'g'), '') // 删掉 word 当中 suggesterKeywords 出现的字符 .replace(/^[#]+/, '#') .replace(/^[/]+/, '/') .split('') @@ -166,7 +167,7 @@ export default class Suggester extends SyntaxBase { this.suggester[configItem.keyword] = configItem; }); - // 反复初始化时, 缓存还在, dom 已更新情况 + // 反复初始化时,缓存还在,dom 已更新情况 if (this.suggesterPanel.hasEditor()) { this.suggesterPanel.editor = null; } @@ -395,7 +396,7 @@ class SuggesterPanel { } /** - * 更新suggesterPanel + * 更新 suggesterPanel * @param {SuggestList} suggestList */ updatePanel(suggestList) { @@ -434,7 +435,7 @@ class SuggesterPanel { } /** - * 渲染suggesterPanel item + * 渲染 suggesterPanel item * @param {string} item 渲染内容 * @param {boolean} selected 是否选中 * @returns {string} html @@ -468,7 +469,7 @@ class SuggesterPanel { relocatePanel(codemirror) { // 找到光标位置来确定候选框位置 let $cursor = this.$cherry.wrapperDom.querySelector('.CodeMirror-cursors .CodeMirror-cursor'); - // 当editor选中某一内容时,".CodeMirror-cursor"会消失,此时通过定位".selected"来确定候选框位置 + // 当 editor 选中某一内容时,".CodeMirror-cursor"会消失,此时通过定位".selected"来确定候选框位置 if (!$cursor) { $cursor = this.$cherry.wrapperDom.querySelector('.CodeMirror-selected'); } @@ -690,7 +691,7 @@ class SuggesterPanel { } /** - * codeMirror change事件 + * codeMirror change 事件 * @param {CodeMirror.Editor} codemirror * @param {CodeMirror.EditorChange} evt * @returns @@ -698,7 +699,6 @@ class SuggesterPanel { onCodeMirrorChange(codemirror, evt) { const { text, from, to, origin } = evt; const changeValue = text.length === 1 ? text[0] : ''; - // 首次输入命中关键词的时候开启联想 if (!this.enableRelate() && this.suggesterConfig[changeValue]) { this.startRelate(codemirror, changeValue, from); @@ -716,9 +716,9 @@ class SuggesterPanel { } // 展示推荐列表 if (typeof this.suggesterConfig[this.keyword]?.suggestList === 'function') { - // 请求api 返回结果拼凑 + // 请求 api 返回结果拼凑 this.suggesterConfig[this.keyword].suggestList(this.searchKeyCache.join(''), (res) => { - // 如果返回了false,则强制退出联想 + // 如果返回了 false,则强制退出联想 if (res === false) { this.stopRelate(); return; @@ -728,6 +728,48 @@ class SuggesterPanel { this.updatePanel(this.optionList); }); } + } + // 处理 ``` 关键字的回车键特殊逻辑 + if (origin === '+input' && changeValue === '') { + const cursor = codemirror.getCursor(); + const lineContent = codemirror.getLine(cursor.line - 1); // 上一行内容 + + // 检查上一行是否是代码块开始标记(三个以上反引号 + 可选语言) + const backtickMatch = lineContent.match(/^[\s]*(`{3,})[^\s]*/); + if (backtickMatch) { + const backticks = backtickMatch[1]; // 获取实际输入的反引号数量 + const language = lineContent.replace(/^[\s]*`{3,}\s*/, ''); // 获取语言部分 + + // 统计整个文档中代码块开始和结束标记的数量 + let codeBlockStarts = 0; + let codeBlockEnds = 0; + const totalLines = codemirror.lineCount(); + + for (let i = 0; i < totalLines; i++) { + const lineText = codemirror.getLine(i); + + // 检查是否为代码块开始标记(三个以上反引号开头,且后面有非空白字符) + if (/^\s*`{3,}[^\s]/.test(lineText)) { + codeBlockStarts++; + } + // 检查是否为代码块结束标记(三个以上反引号开头,后面只有空白) + else if (/^\s*`{3,}\s*$/.test(lineText)) { + codeBlockEnds++; + } + } + // 是否存在未闭合的代码块 + const hasUnclosedCodeBlockBefore = (codeBlockStarts + codeBlockEnds) % 2 === 1; + // Logger.log('hasUnclosedCodeBlockBefore', hasUnclosedCodeBlockBefore); + + if (hasUnclosedCodeBlockBefore) { + // 存在未闭合,应该补全整个代码块结构 + const codeBlock = `${backticks}${language}\n\n${backticks}`; + codemirror.replaceRange(codeBlock, { line: cursor.line - 1, ch: 0 }, { line: cursor.line, ch: 0 }); + codemirror.setCursor(cursor.line, language.length + backticks.length + 1); + } + this.stopRelate(); + return; + } } } @@ -805,9 +847,9 @@ class SuggesterPanel { const viewTop = this.$suggesterPanel.scrollTop; // 可视区域范围下端 const viewBottom = viewTop + suggestPanelHeight; - // item的上端 + // item 的上端 const nextEleTop = nextElement.offsetTop; - // item高度 + // item 高度 const nextEleHeight = nextElement.offsetHeight; // 当前元素全部或部分在可视区域之外,就滚动 if (nextEleTop < viewTop || nextEleTop + nextEleHeight > viewBottom) { @@ -829,7 +871,7 @@ class SuggesterPanel { this.stopRelate(); }, 0); } else if (keyCode === 27 || keyCode === 0x25 || keyCode === 0x27) { - // 按下esc或者←、→键的时候退出联想 + // 按下 esc 或者←、→键的时候退出联想 evt.stopPropagation(); codemirror.focus(); setTimeout(() => {