diff --git a/.changeset/codemirror-v6-upgrade.md b/.changeset/codemirror-v6-upgrade.md new file mode 100644 index 000000000..cef129123 --- /dev/null +++ b/.changeset/codemirror-v6-upgrade.md @@ -0,0 +1,9 @@ +--- +'cherry-markdown': minor +--- + +- refactor: 升级 CodeMirror 到 v6 + - 将 CodeMirror 从 v5 升级到 v6,重构 CM6Adapter 适配器 + - 优化特殊字符标记处理性能 + - 修复选区映射、正则处理、Bubble 事件等问题 + - 支持 vim 模式懒加载(@replit/codemirror-vim) diff --git a/examples/assets/drawio_lib/resources/zh.txt b/examples/assets/drawio_lib/resources/zh.txt index a1887c4e8..9c30b0518 100644 --- a/examples/assets/drawio_lib/resources/zh.txt +++ b/examples/assets/drawio_lib/resources/zh.txt @@ -9,7 +9,7 @@ addProperty=添加属性 address=地址 addToExistingDrawing=添加至当前的图纸 addWaypoint=添加航点 -adjustTo=调至 +adjustTo=跳至 advanced=高级 align=对齐 alignment=对齐 diff --git a/examples/assets/scripts/chatgpt-demo.js b/examples/assets/scripts/chatgpt-demo.js index 1b54b022a..b7a43f691 100644 --- a/examples/assets/scripts/chatgpt-demo.js +++ b/examples/assets/scripts/chatgpt-demo.js @@ -66,7 +66,7 @@ var customMenuC = Cherry.createMenuHook('帮助中心', { onClick: (selection, type) => { switch (type) { case 'shortKey': - return `${selection}快捷键看这里:https://codemirror.net/5/demo/sublime.html`; + return `${selection}快捷键看这里:https://codemirror.net/docs/ref/#commands`; case 'github': return `${selection}我们在这里:https://github.com/Tencent/cherry-markdown`; case 'release': diff --git a/examples/assets/scripts/index-demo.js b/examples/assets/scripts/index-demo.js index 51f09d113..ad2c30a6d 100644 --- a/examples/assets/scripts/index-demo.js +++ b/examples/assets/scripts/index-demo.js @@ -71,7 +71,7 @@ var customMenuC = Cherry.createMenuHook('自定义菜单+子菜单', { onClick: (selection, type) => { switch (type) { case 'shortKey': - return `${selection}快捷键看这里:https://codemirror.net/5/demo/sublime.html`; + return `${selection}快捷键看这里:https://codemirror.net/docs/ref/#commands`; case 'github': return `${selection}我们在这里:https://github.com/Tencent/cherry-markdown`; case 'release': diff --git a/examples/vim.html b/examples/vim.html index f0820046b..842aba782 100644 --- a/examples/vim.html +++ b/examples/vim.html @@ -43,6 +43,6 @@ }; var sourceCode = document.documentElement.outerHTML; var cherry = new Cherry(config); -cherry.setValue(`## 移动光标\n- h j k l 上 下 左 右\n- w 跳到下一个字首,按标点或单词分割\n- W 跳到下一个字首,长跳,如end-of-line被认为是一个字\n- e 跳到下一个字尾\n- E 跳到下一个字尾,长跳\n- b 跳到上一个字\n- B 跳到上一个字,长跳\n- ^ 跳至行首的第一个字符\n- gg 跳至文首\n- G 调至文尾\n- 5gg/5G 调至第5行\n## 删除复制\n- dd 删除光标所在行\n- dw 删除一个字(word)\n- d/D 删除到行末\n- x 删除当前字符\n- X 删除前一个字符\n## 插入模式\n- i 从当前光标处进入插入模式\n- I 进入插入模式,并置光标于行首\n- a 追加模式,置光标于当前光标之后\n- A 追加模式,置光标于行末\n- o 在当前行之下新加一行,并进入插入模式\n- O 在当前行之上新加一行,并进入插入模式\n- Esc 退出插入模式\n## 更多\n查看[更多](https://codemirror.net/5/keymap/vim.js)\n`); + cherry.setValue(`## 移动光标\n- h j k l 上 下 左 右\n- w 跳到下一个字首,按标点或单词分割\n- W 跳到下一个字首,长跳,如end-of-line被认为是一个字\n- e 跳到下一个字尾\n- E 跳到下一个字尾,长跳\n- b 跳到上一个字\n- B 跳到上一个字,长跳\n- ^ 跳至行首的第一个字符\n- gg 跳到文首\n- G 跳至文尾\n- 5gg/5G 跳至第5行\n## 删除复制\n- dd 删除光标所在行\n- dw 删除一个字(word)\n- d/D 删除到行末\n- x 删除当前字符\n- X 删除前一个字符\n## 插入模式\n- i 从当前光标处进入插入模式\n- I 进入插入模式,并置光标于行首\n- a 追加模式,置光标于当前光标之后\n- A 追加模式,置光标于行末\n- o 在当前行之下新加一行,并进入插入模式\n- O 在当前行之上新加一行,并进入插入模式\n- Esc 退出插入模式\n## 更多\n查看[更多](https://github.com/replit/codemirror-vim?tab=readme-ov-file#vim-keybindings-for-cm6)\n`); diff --git a/packages/cherry-markdown/CHANGELOG.md b/packages/cherry-markdown/CHANGELOG.md index 9b2f6cd2b..0776e5234 100644 --- a/packages/cherry-markdown/CHANGELOG.md +++ b/packages/cherry-markdown/CHANGELOG.md @@ -1,5 +1,23 @@ # Change Log +## 0.11.0-alpha.0 + +### Minor Changes + +- 3a1f53f: refactor: 升级编辑器至 `Codemirror@6` 并优化相关功能 + +### Patch Changes + +- f5e01e9: fix: #1570 修复点击脚注列表里的标号时有js报错的问题 +- 68017a4: chore:`@types/node` 升级为 `@20.10.6` +- b559a2a: chore: 将 release build 的 Node 版本设置为 `18.x` +- 755dd8c: fix: 优化拖拽预览区宽度的逻辑,使其更稳定 +- c15f54f: style: 增加主题和代码块主题的图标 +- 5d2d0be: fix: 修复表格同一个单元格内无法连续输入\|的问题 +- 2478d68: fix: 移除工具栏高度动态更新逻辑,简化任务栏高度变量管理 +- e069033: feat(sidebar): 添加侧边栏列表样式和动态高度支持 +- a275692: fix: image syntax compatibility, Fixes #1554 + ## 0.10.3 ### Patch Changes diff --git a/packages/cherry-markdown/build/rollup.core.config.js b/packages/cherry-markdown/build/rollup.core.config.js index e89c568df..fd41d2d3e 100644 --- a/packages/cherry-markdown/build/rollup.core.config.js +++ b/packages/cherry-markdown/build/rollup.core.config.js @@ -51,5 +51,6 @@ if (!Array.isArray(options.external)) { options.external = []; } options.external.push('mermaid'); +options.external.push('@replit/codemirror-vim'); // 保持 vim 模块懒加载,避免 code-splitting export default options; diff --git a/packages/cherry-markdown/index.html b/packages/cherry-markdown/index.html index 9b664255b..8678eaa29 100644 --- a/packages/cherry-markdown/index.html +++ b/packages/cherry-markdown/index.html @@ -79,7 +79,7 @@ // 相对路径:拼接本地示例路径 console.log('加载本地图片', '/@fs/' + __EXAMPLES_PATH__ + '/' + url); return '/@fs/' + __EXAMPLES_PATH__ + '/' + url; - }, + }, }, }; diff --git a/packages/cherry-markdown/package.json b/packages/cherry-markdown/package.json index d2a4aa503..b0a0cda4d 100644 --- a/packages/cherry-markdown/package.json +++ b/packages/cherry-markdown/package.json @@ -109,6 +109,15 @@ "vite": "^6.4.1" }, "dependencies": { + "@codemirror/autocomplete": "^6.18.6", + "@codemirror/commands": "^6.8.1", + "@codemirror/lang-markdown": "^6.3.2", + "@codemirror/language": "^6.12.2", + "@codemirror/search": "^6.5.10", + "@codemirror/state": "^6.5.2", + "@codemirror/view": "^6.40.0", + "@lezer/highlight": "^1.2.1", + "@replit/codemirror-vim": "^6.3.0", "@types/codemirror": "^0.0.108", "crypto-js": "^4.2.0", "dompurify": "^3.2.6", diff --git a/packages/cherry-markdown/src/Cherry.js b/packages/cherry-markdown/src/Cherry.js index 624f5fe0f..da4077709 100644 --- a/packages/cherry-markdown/src/Cherry.js +++ b/packages/cherry-markdown/src/Cherry.js @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { EditorView } from '@codemirror/view'; import mergeWith from 'lodash/mergeWith'; import Editor from './Editor'; import Engine from './Engine'; @@ -48,6 +49,7 @@ import { CherryStatic } from './CherryStatic'; import { LIST_CONTENT } from '@/utils/regexp'; /** @typedef {import('~types/cherry').CherryOptions} CherryOptions */ +/** @typedef {import('~types/editor').CM6Adapter} CM6AdapterType */ export default class Cherry extends CherryStatic { /** * @protected @@ -276,11 +278,19 @@ export default class Cherry extends CherryStatic { } destroy() { + // 先销毁编辑器实例(清理 EditorView 和资源) + if (this.editor) { + this.editor.destroy(); + } + + // 清理 DOM if (this.noMountEl) { this.cherryDom.remove(); } else { this.wrapperDom.remove(); } + + // 清理事件 this.$event.clearAll(); } @@ -371,20 +381,40 @@ export default class Cherry extends CherryStatic { * 一般纯预览模式和纯编辑模式适合在屏幕较小的终端使用,比如手机移动端 */ switchModel(model = 'edit&preview', showToolbar = true) { - let isShowToolbar = showToolbar; switch (model) { case 'edit&preview': - this.previewer.editAndPreview(); + if (this.previewer) { + this.previewer.editOnly(); + this.previewer.recoverPreviewer(); + } + if (this.toolbar && showToolbar) { + this.toolbar.showToolbar(); + } + if (showToolbar) { + this.wrapperDom.classList.remove('cherry--no-toolbar'); + } else { + this.wrapperDom.classList.add('cherry--no-toolbar'); + } break; case 'editOnly': - this.previewer.editOnly(); + if (!this.previewer.isPreviewerHidden()) { + this.previewer.editOnly(); + } + if (this.toolbar && showToolbar) { + this.toolbar.showToolbar(); + } + if (showToolbar) { + this.wrapperDom.classList.remove('cherry--no-toolbar'); + } else { + this.wrapperDom.classList.add('cherry--no-toolbar'); + } break; case 'previewOnly': this.previewer.previewOnly(); - isShowToolbar = false; + this.toolbar && this.toolbar.previewOnly(); + this.wrapperDom.classList.add('cherry--no-toolbar'); break; } - this.toolbar && this.toolbar.showOrHideToolbar(isShowToolbar); } /** @@ -409,7 +439,7 @@ export default class Cherry extends CherryStatic { * @returns markdown源码内容 */ getValue() { - return this.editor?.editor?.getValue() || ''; + return this.editor?.editor?.view?.state?.doc?.toString() || ''; } /** @@ -422,10 +452,10 @@ export default class Cherry extends CherryStatic { /** * 获取CodeMirror 实例 - * @returns { CodeMirror.Editor } CodeMirror实例 + * @returns { EditorView } CodeMirror 6 适配器实例 */ getCodeMirror() { - return this.editor.editor; + return this.editor.editor.view; } /** @@ -474,18 +504,30 @@ export default class Cherry extends CherryStatic { */ setValue(content, keepCursor = false) { if (keepCursor === false) { - this.editor.editor.setValue(content); + this.editor.setValue(content); + return; } - const { top } = this.editor.editor.getScrollInfo(); - const codemirror = this.editor.editor; + + const editorView = this.editor.editor; + const currentScrollTop = editorView.scrollDOM.scrollTop; const old = this.getValue(); - const pos = codemirror.getDoc().indexFromPos(codemirror.getCursor()); - const newPos = getPosBydiffs(pos, old, content); - codemirror.setValue(content); - const cursor = codemirror.getDoc().posFromIndex(newPos); - codemirror.setCursor(cursor); + + // 获取当前光标位置 + const currentPos = editorView.state.selection.main.head; + const newPos = getPosBydiffs(currentPos, old, content); + + // 更新内容并保持光标位置 + editorView.dispatch({ + changes: { + from: 0, + to: editorView.state.doc.length, + insert: content, + }, + selection: { anchor: Math.min(newPos, content.length) }, + }); + this.editor.dealSpecialWords(); - this.editor.editor.scrollTo(null, top); + editorView.scrollDOM.scrollTop = currentScrollTop; } /** @@ -496,11 +538,43 @@ export default class Cherry extends CherryStatic { * @param {boolean} [focus=true] 保持编辑器处于focus状态 */ insert(content, isSelect = false, anchor = false, focus = true) { + const editorView = this.editor.editor; + let insertPos; + if (anchor) { - this.editor.editor.setSelection({ line: anchor[0], ch: anchor[1] }, { line: anchor[0], ch: anchor[1] }); + // 计算指定位置的文档偏移量 + const line = editorView.state.doc.line(anchor[0] + 1); + insertPos = line.from + anchor[1]; + } else { + // 使用当前光标位置 + insertPos = editorView.state.selection.main.head; + } + + const transaction = { + changes: { + from: insertPos, + to: insertPos, + insert: content, + }, + }; + + if (isSelect) { + // 选中插入的内容 + transaction.selection = { + anchor: insertPos, + head: insertPos + content.length, + }; + } else { + // 光标移到插入内容的末尾 + transaction.selection = { + anchor: insertPos + content.length, + }; + } + + editorView.dispatch(transaction); + if (focus) { + editorView.view.focus(); } - this.editor.editor.replaceSelection(content, isSelect ? 'around' : 'end'); - focus && this.editor.editor.focus(); } /** @@ -919,11 +993,13 @@ export default class Cherry extends CherryStatic { /** * @private - * @param {import('codemirror').Editor} codemirror + * @param {EditorView | Object} editorView */ - initText(codemirror) { + initText(editorView) { try { - const markdownText = codemirror.getValue(); + // 兼容 CM6Adapter,如果传入的是 adapter,则获取其内部的 view + const view = editorView.view || editorView; + const markdownText = view.state.doc.toString(); this.lastMarkdownText = markdownText; const html = this.engine.makeHtml(markdownText); if (this.options.editor.defaultModel === 'editOnly') { @@ -940,19 +1016,29 @@ export default class Cherry extends CherryStatic { /** * @private * @param {Event} _evt - * @param {import('codemirror').Editor} codemirror + * @param {EditorView} editorView + */ + /** + * 编辑器内容变更时触发,更新预览区内容 + * @private + * @param {Event} _evt - 编辑事件对象(未使用) + * @param {EditorView | Object} editorView - 编辑器实例 */ - editText(_evt, codemirror) { + editText(_evt, editorView) { try { + // 兼容 CM6Adapter,如果传入的是 adapter,则获取其内部的 view + const view = editorView.view || editorView; + + // 如果已有定时器,先清除,避免多次触发 if (this.timer) { clearTimeout(this.timer); this.timer = null; } let interval = this.options.engine.global.flowSessionContext ? 10 : 50; // 每多100行,增加1ms的延迟 - interval += this.editor.editor.lineCount() / 100; + interval += this.editor.editor.view.state.doc.lines / 100; this.timer = setTimeout(() => { - const markdownText = codemirror.getValue(); + const markdownText = view.state.doc.toString(); if (markdownText !== this.lastMarkdownText) { this.lastMarkdownText = markdownText; const html = this.engine.makeHtml(markdownText); @@ -962,9 +1048,14 @@ export default class Cherry extends CherryStatic { html, }); } - // 强制每次编辑(包括undo、redo)编辑器都会自动滚动到光标位置 + // 强制每次编辑(包括undo、redo)编辑器都会自动滚动到光标位置 if (!this.options.editor.keepDocumentScrollAfterInit) { - codemirror.scrollIntoView(null); + view.dispatch({ + effects: EditorView.scrollIntoView(view.state.selection.main.from, { + y: 'nearest', + x: 'nearest', + }), + }); } }, interval); } catch (e) { @@ -977,9 +1068,10 @@ export default class Cherry extends CherryStatic { * @param {any} cb */ onChange(cb) { - this.editor.editor.on('change', (codeMirror) => { + // CodeMirror 6 使用事件系统,通过 $event 来监听变化 + this.$event.on('afterChange', () => { cb({ - markdown: codeMirror.getValue(), // 后续可以按需增加html或其他状态 + markdown: this.editor.editor.view.state.doc.toString(), // CodeMirror 6 API }); }); } @@ -989,17 +1081,28 @@ export default class Cherry extends CherryStatic { * @param {KeyboardEvent} evt */ fireShortcutKey(evt) { - const cursor = this.editor.editor.getCursor(); - const lineContent = this.editor.editor.getLine(cursor.line); + // 获取当前光标位置 - CodeMirror 6 API + const { view } = this.editor.editor; + const selection = view.state.selection.main; + const pos = selection.head; + const line = view.state.doc.lineAt(pos); + const lineContent = line.text; + const cursor = { line: line.number - 1, ch: pos - line.from }; + // shift + tab 已经被绑定为缩进,所以这里不做处理 if (!evt.shiftKey && evt.key === 'Tab' && LIST_CONTENT.test(lineContent)) { // 每按一次Tab,如果当前光标在行首或者行尾,就在行首加一个\t if (cursor.ch === 0 || cursor.ch === lineContent.length || cursor.ch === lineContent.length + 1) { evt.preventDefault(); - this.editor.editor.setSelection({ line: cursor.line, ch: 0 }, { line: cursor.line, ch: lineContent.length }); - this.editor.editor.replaceSelection(`\t${lineContent}`, 'around'); - const newCursor = this.editor.editor.getCursor(); - this.editor.editor.setSelection(newCursor, newCursor); + // 使用 CodeMirror 6 API 替换整行内容 + view.dispatch({ + changes: { + from: line.from, + to: line.to, + insert: `\t${lineContent}`, + }, + selection: { anchor: line.from + cursor.ch + 1 }, + }); } } if (this.toolbar.matchShortcutKey(evt)) { diff --git a/packages/cherry-markdown/src/Editor.js b/packages/cherry-markdown/src/Editor.js index 1091c99dc..45e75fc97 100644 --- a/packages/cherry-markdown/src/Editor.js +++ b/packages/cherry-markdown/src/Editor.js @@ -14,46 +14,953 @@ * limitations under the License. */ // @ts-check -import codemirror from 'codemirror'; -// import 'codemirror/mode/markdown/markdown'; -import 'codemirror/mode/gfm/gfm'; // https://codemirror.net/mode/gfm/index.html -import 'codemirror/mode/yaml-frontmatter/yaml-frontmatter'; // https://codemirror.net/5/mode/yaml-frontmatter/index.html -// import 'codemirror/mode/xml/xml'; -import 'codemirror/addon/edit/continuelist'; -import 'codemirror/addon/edit/closetag'; -import 'codemirror/addon/fold/xml-fold'; -import 'codemirror/addon/edit/matchtags'; -import 'codemirror/addon/display/placeholder'; -import 'codemirror/keymap/sublime'; -import 'codemirror/keymap/vim'; - -// import 'cm-search-replace/src/search'; -import 'codemirror/addon/search/searchcursor'; -import 'codemirror/addon/scroll/annotatescrollbar'; -import 'codemirror/addon/search/matchesonscrollbar'; -// import 'codemirror/addon/selection/active-line'; -// import 'codemirror/addon/edit/matchbrackets'; +import { + EditorView, + keymap, + placeholder, + lineNumbers, + Decoration, + WidgetType, + drawSelection, + highlightActiveLine, + highlightActiveLineGutter, + ViewPlugin, + rectangularSelection, +} from '@codemirror/view'; +import { EditorState, StateEffect, StateField, EditorSelection, Transaction, Compartment } from '@codemirror/state'; +import { markdown } from '@codemirror/lang-markdown'; +import { search, searchKeymap, SearchQuery } from '@codemirror/search'; +import { history, historyKeymap, defaultKeymap, indentWithTab } from '@codemirror/commands'; +import { closeBrackets, closeBracketsKeymap } from '@codemirror/autocomplete'; +import { syntaxHighlighting, defaultHighlightStyle, foldGutter, indentOnInput } from '@codemirror/language'; import htmlParser from '@/utils/htmlparser'; import pasteHelper from '@/utils/pasteHelper'; -import { addEvent } from './utils/event'; import Logger from '@/Logger'; import { handleFileUploadCallback } from '@/utils/file'; +import { tagHighlighter, tags } from '@lezer/highlight'; import { createElement } from './utils/dom'; -import { base64Reg, imgDrawioXmlReg, createUrlReg, pasteWrapperReg } from './utils/regexp'; +import { base64Reg, imgDrawioXmlReg, createUrlReg, getCodeBlockRule, pasteWrapperReg } from './utils/regexp'; +import { addEvent, removeEvent } from './utils/event'; import { handleNewlineIndentList } from './utils/autoindent'; -import { getCodeBlockRule } from '@/utils/regexp'; + +/** + * 自定义语法高亮器 - 将 Lezer tags 映射为 cm-* 类名 + * 用于保持样式兼容性 + */ +const cherryHighlighter = tagHighlighter([ + { tag: tags.string, class: 'cm-string' }, + { tag: tags.special(tags.string), class: 'cm-string-2' }, + { tag: tags.number, class: 'cm-number' }, + { tag: tags.keyword, class: 'cm-keyword' }, + { tag: tags.comment, class: 'cm-comment' }, + { tag: tags.lineComment, class: 'cm-comment' }, + { tag: tags.blockComment, class: 'cm-comment' }, + { tag: tags.docComment, class: 'cm-comment' }, + { tag: tags.variableName, class: 'cm-variable' }, + { tag: tags.definition(tags.variableName), class: 'cm-def' }, + { tag: tags.function(tags.variableName), class: 'cm-variable-2' }, + { tag: tags.local(tags.variableName), class: 'cm-variable' }, + { tag: tags.special(tags.variableName), class: 'cm-variable-3' }, + { tag: tags.propertyName, class: 'cm-property' }, + { tag: tags.definition(tags.propertyName), class: 'cm-property' }, + { tag: tags.special(tags.propertyName), class: 'cm-property' }, + { tag: tags.operator, class: 'cm-operator' }, + { tag: tags.arithmeticOperator, class: 'cm-operator' }, + { tag: tags.logicOperator, class: 'cm-operator' }, + { tag: tags.bitwiseOperator, class: 'cm-operator' }, + { tag: tags.compareOperator, class: 'cm-operator' }, + { tag: tags.updateOperator, class: 'cm-operator' }, + { tag: tags.definitionOperator, class: 'cm-operator' }, + { tag: tags.controlOperator, class: 'cm-operator' }, + { tag: tags.derefOperator, class: 'cm-operator' }, + { tag: tags.url, class: 'cm-url' }, + { tag: tags.link, class: 'cm-link' }, + { tag: tags.atom, class: 'cm-atom' }, + { tag: tags.bool, class: 'cm-atom' }, + { tag: tags.null, class: 'cm-atom' }, + { tag: tags.self, class: 'cm-atom' }, + { tag: tags.meta, class: 'cm-meta' }, + { tag: tags.annotation, class: 'cm-meta' }, + { tag: tags.modifier, class: 'cm-meta' }, + { tag: tags.heading, class: 'cm-header' }, + { tag: tags.heading1, class: 'cm-header cm-header-1' }, + { tag: tags.heading2, class: 'cm-header cm-header-2' }, + { tag: tags.heading3, class: 'cm-header cm-header-3' }, + { tag: tags.heading4, class: 'cm-header cm-header-4' }, + { tag: tags.heading5, class: 'cm-header cm-header-5' }, + { tag: tags.heading6, class: 'cm-header cm-header-6' }, + { tag: tags.emphasis, class: 'cm-em' }, + { tag: tags.strong, class: 'cm-strong' }, + { tag: tags.strikethrough, class: 'cm-strikethrough' }, + { tag: tags.quote, class: 'cm-quote' }, + { tag: tags.list, class: 'cm-list' }, + { tag: tags.contentSeparator, class: 'cm-hr' }, + { tag: tags.typeName, class: 'cm-type' }, + { tag: tags.className, class: 'cm-type' }, + { tag: tags.namespace, class: 'cm-qualifier' }, + { tag: tags.labelName, class: 'cm-tag' }, + { tag: tags.tagName, class: 'cm-tag' }, + { tag: tags.angleBracket, class: 'cm-bracket' }, + { tag: tags.attributeName, class: 'cm-attribute' }, + { tag: tags.attributeValue, class: 'cm-string' }, + { tag: tags.paren, class: 'cm-bracket' }, + { tag: tags.squareBracket, class: 'cm-bracket' }, + { tag: tags.brace, class: 'cm-bracket' }, + { tag: tags.punctuation, class: 'cm-punctuation' }, + { tag: tags.separator, class: 'cm-punctuation' }, + { tag: tags.escape, class: 'cm-escape' }, + { tag: tags.regexp, class: 'cm-string-2' }, + { tag: tags.monospace, class: 'cm-comment' }, + { tag: tags.processingInstruction, class: 'cm-meta' }, + { tag: tags.invalid, class: 'cm-invalidchar' }, + { tag: tags.character, class: 'cm-string' }, +]); /** * @typedef {import('~types/editor').EditorConfiguration} EditorConfiguration - * @typedef {import('~types/editor').EditorEventCallback} EditorEventCallback - * @typedef {import('codemirror')} CodeMirror + * @typedef {import('~types/editor').EditorEventCallback} EditorEventCallback + * @typedef {import('~types/editor').CM6Adapter} CM6AdapterType + * @typedef {import('~types/editor').TextMarker} TextMarker + * @typedef {import('~types/editor').MarkInfo} MarkInfo + * @typedef {import('~types/editor').MarkTextOptions} MarkTextOptions + * @typedef {import('~types/editor').SearchCursor} SearchCursor + * @typedef {import('~types/editor').ScrollInfo} ScrollInfo + * @typedef {import('@codemirror/state').SelectionRange} SelectionRange + * @typedef {import('@codemirror/view').Rect} Rect + */ + +/** + * @typedef {Object} MarkEffectValue + * @property {number} from - 起始位置(文档偏移量) + * @property {number} to - 结束位置(文档偏移量) + * @property {Decoration} [decoration] - 装饰对象 + * @property {MarkTextOptions} [options] - 标记选项 + * @property {string} [markId] - 用于追踪 mark 的唯一标识符 + */ + +// 注意:keymapCompartment 和 vimCompartment 已移至 Editor 类实例属性 + +// vim 模块缓存 +let vimModule = null; +let vimModuleLoadPromise = null; + +/** + * 动态加载 vim 模块 + * @returns {Promise} vim 模块 + */ +async function loadVimModule() { + if (vimModule) { + return vimModule; + } + if (vimModuleLoadPromise) { + return vimModuleLoadPromise; + } + + vimModuleLoadPromise = (async () => { + try { + const mod = await import('@replit/codemirror-vim'); + vimModule = mod; + return mod; + } catch (e) { + vimModuleLoadPromise = null; + Logger.error('Failed to load @replit/codemirror-vim. Please install it: npm install @replit/codemirror-vim'); + throw e; + } + })(); + + return vimModuleLoadPromise; +} + +// 缓存语法高亮扩展 +const cachedCherryHighlighting = syntaxHighlighting(cherryHighlighter); +const cachedDefaultHighlighting = syntaxHighlighting(defaultHighlightStyle); + +// 搜索高亮效果 +/** @type {import('@codemirror/state').StateEffectType} */ +const setSearchHighlightEffect = StateEffect.define(); + +/** + * 搜索高亮的 ViewPlugin(增量更新,只处理可见区域) + */ +const searchHighlightField = ViewPlugin.fromClass( + class { + /** + * @param {EditorView} view + */ + constructor(view) { + /** @type {RegExp | null} */ + this.query = null; + /** @type {import('@codemirror/view').DecorationSet} */ + this.decorations = Decoration.none; + this.buildDecorations(view); + } + + /** + * @param {import('@codemirror/view').ViewUpdate} update + */ + update(update) { + const shouldRebuild = + update.docChanged || + update.viewportChanged || + update.transactions.some((tr) => tr.effects.some((e) => e.is(setSearchHighlightEffect))); + + if (shouldRebuild) { + this.buildDecorations(update.view); + } + } + + /** + * @param {EditorView} view + */ + buildDecorations(view) { + if (!this.query) { + this.decorations = Decoration.none; + return; + } + + const decorations = []; + + for (const { from, to } of view.visibleRanges) { + const text = view.state.doc.sliceString(from, to); + const tempQuery = new RegExp(this.query.source, this.query.flags); + tempQuery.lastIndex = 0; + + let match; + while ((match = tempQuery.exec(text)) !== null) { + const matchFrom = from + match.index; + const matchTo = matchFrom + match[0].length; + + decorations.push( + Decoration.mark({ + class: 'cm-searching', + }).range(matchFrom, matchTo), + ); + + if (match[0].length === 0) { + tempQuery.lastIndex += 1; + } + } + } + + this.decorations = Decoration.set(decorations.sort((a, b) => a.from - b.from)); + } + + destroy() { + this.query = null; + this.decorations = Decoration.none; + } + }, + { + decorations: (v) => v.decorations, + }, +); + +/** + * CodeMirror 6 适配器 + * 提供对 EditorView 的封装,使用 CM6 原生类型 + * @implements {CM6AdapterType} */ +class CM6Adapter { + /** + * 创建 CM6Adapter 实例 + * @param {EditorView} view - EditorView 实例 + * @param {Compartment} [vimCompartment] - vim 模式的 Compartment(可选,用于多实例隔离) + */ + constructor(view, vimCompartment) { + /** @type {EditorView} */ + this.view = view; + /** @type {Map void>>} */ + this.eventHandlers = new Map(); + /** @type {'sublime' | 'vim'} */ + this.currentKeyMap = 'sublime'; + /** @type {Compartment | null} */ + this.vimCompartment = vimCompartment || null; + /** @type {number} 实例级 markId 计数器 */ + this.markIdCounter = 0; + } + + /** + * 获取编辑器状态 + * @returns {EditorState} + */ + get state() { + return this.view.state; + } + + /** + * 获取滚动容器 DOM 元素 + * @returns {HTMLElement} + */ + get scrollDOM() { + return this.view.scrollDOM; + } + + /** + * 分发事务到编辑器 + * @param {...import('@codemirror/state').TransactionSpec} specs + * @returns {void} + */ + dispatch(...specs) { + return this.view.dispatch(...specs); + } + + /** + * 请求测量 + * @template T + * @param {{ read: (view: EditorView) => T; write?: (measure: T, view: EditorView) => void }} [request] + * @returns {void} + */ + requestMeasure(request) { + return this.view.requestMeasure(request); + } + + /** + * 坐标转位置 + * @param {{ x: number; y: number }} coords + * @returns {number | null} + */ + posAtCoords(coords) { + return this.view.posAtCoords(coords); + } + + /** + * 获取行块信息 + * @param {number} pos + * @returns {import('@codemirror/view').BlockInfo} + */ + lineBlockAt(pos) { + return this.view.lineBlockAt(pos); + } + + /** + * 获取所有选区的文本 + * @returns {string[]} 所有选区文本的数组 + */ + getSelections() { + return this.view.state.selection.ranges.map((range) => this.view.state.doc.sliceString(range.from, range.to)); + } + + /** + * 替换当前选中的文本 + * @param {string} text - 替换文本 + * @param {'around' | 'start'} [select='around'] - 替换后的选区行为 + * - 'around': 光标移动到替换文本末尾 + * - 'start': 光标移动到替换文本开头 + * @returns {void} + */ + replaceSelection(text, select = 'around') { + const { from, to } = this.view.state.selection.main; + let selection; + + if (select === 'start') { + selection = { anchor: from }; + } else { + selection = { anchor: from + text.length }; + } + + this.view.dispatch({ + changes: { from, to, insert: text }, + selection, + }); + } + + /** + * 替换多个选区的文本 + * @param {string[]} texts - 替换文本数组 + * @param {'around' | 'start'} [select='around'] - 替换后的选区行为 + * @returns {void} + */ + replaceSelections(texts, select = 'around') { + const { ranges } = this.view.state.selection; + const changes = ranges.map((range, i) => ({ + from: range.from, + to: range.to, + insert: texts[i] || '', + })); + + let newSelections; + if (select === 'around') { + let offset = 0; + newSelections = ranges.map((range, i) => { + const text = texts[i] || ''; + const newFrom = range.from + offset; + const newTo = newFrom + text.length; + offset += text.length - (range.to - range.from); + return EditorSelection.range(newTo, newTo); + }); + } else if (select === 'start') { + let offset = 0; + newSelections = ranges.map((range, i) => { + const text = texts[i] || ''; + const newFrom = range.from + offset; + offset += text.length - (range.to - range.from); + return EditorSelection.range(newFrom, newFrom); + }); + } + + this.view.dispatch({ + changes, + selection: newSelections ? EditorSelection.create(newSelections) : undefined, + }); + } + + /** + * 设置选区 + * @param {number} anchor - 选区锚点(文档偏移量) + * @param {number} [head] - 选区头部(文档偏移量),不传则与 anchor 相同 + * @param {Object} [options] + * @param {string} [options.userEvent] - 用户事件类型 + * @param {boolean} [options.scrollIntoView] - 是否滚动到选区位置 + * @returns {void} + */ + setSelection(anchor, head, options = {}) { + const docLength = this.view.state.doc.length; + const headPos = head !== undefined ? head : anchor; + const safeAnchor = Math.max(0, Math.min(anchor, docLength)); + const safeHead = Math.max(0, Math.min(headPos, docLength)); + const dispatchOptions = { selection: { anchor: safeAnchor, head: safeHead } }; + + if (options.userEvent) { + dispatchOptions.annotations = Transaction.userEvent.of(options.userEvent); + } + + if (options.scrollIntoView) { + dispatchOptions.effects = EditorView.scrollIntoView(safeHead); + } + + this.view.dispatch(dispatchOptions); + } + + /** + * 获取所有选区 + * @returns {readonly SelectionRange[]} CM6 SelectionRange 数组 + */ + listSelections() { + return this.view.state.selection.ranges; + } + + /** + * 替换指定范围的文本 + * @param {string} text - 替换文本 + * @param {number} from - 起始位置(文档偏移量) + * @param {number} [to] - 结束位置(文档偏移量),不传则在 from 位置插入 + * @returns {void} + */ + replaceRange(text, from, to) { + const docLength = this.view.state.doc.length; + const toPos = to !== undefined ? to : from; + const safeFrom = Math.max(0, Math.min(from, docLength)); + const safeTo = Math.max(safeFrom, Math.min(toPos, docLength)); + this.view.dispatch({ + changes: { from: safeFrom, to: safeTo, insert: text }, + }); + } + + /** + * 获取文档对象 + * @CM5_COMPAT 兼容 CodeMirror 5 API,返回自身以便链式调用 + * @returns {CM6Adapter} + */ + getDoc() { + return this; + } + + /** + * 获取指定位置的屏幕坐标 + * @param {number} [pos] - 文档位置(偏移量),不传则使用当前光标位置 + * @returns {Rect | null} 坐标对象 {left, top, bottom, right} 或 null + */ + cursorCoords(pos) { + const position = pos !== undefined ? pos : this.view.state.selection.main.head; + return this.view.coordsAtPos(position); + } + + /** + * 将指定位置滚动到可视区域 + * @CM5_COMPAT 兼容 CodeMirror 5 API,内部已改用 EditorView.scrollIntoView effect + * @param {number} pos - 文档位置(偏移量) + * @returns {void} + */ + scrollIntoView(pos) { + this.view.dispatch({ + effects: EditorView.scrollIntoView(pos), + }); + } + + /** + * 设置编辑器选项 + * @param {'value' | 'keyMap' | string} option - 选项名称 + * @param {string | boolean | object} value - 选项值 + * @returns {void} + */ + setOption(option, value) { + switch (option) { + case 'value': + this.view.dispatch({ + changes: { from: 0, to: this.view.state.doc.length, insert: /** @type {string} */ (value) }, + }); + break; + case 'keyMap': + this.setKeyMap(/** @type {'sublime' | 'vim'} */ (value)); + break; + default: + break; + } + } + + /** + * 设置键盘映射模式 + * @param {'sublime' | 'vim'} mode - 'sublime' 或 'vim' 模式 + * @returns {Promise} + */ + async setKeyMap(mode) { + if (!this.vimCompartment) { + console.warn('vimCompartment not available, cannot switch keyMap'); + return; + } + + if (mode === 'vim') { + try { + const vimMod = await loadVimModule(); + this.view.dispatch({ + effects: this.vimCompartment.reconfigure(vimMod.vim()), + }); + this.currentKeyMap = 'vim'; + } catch (e) { + console.error('Failed to load vim module, falling back to sublime mode:', e); + this.view.dispatch({ + effects: this.vimCompartment.reconfigure([]), + }); + this.currentKeyMap = 'sublime'; + throw new Error('Failed to switch to vim mode. Using sublime mode instead.'); + } + } else { + this.view.dispatch({ + effects: this.vimCompartment.reconfigure([]), + }); + this.currentKeyMap = 'sublime'; + } + } + + /** + * 获取编辑器选项 + * @param {'readOnly' | 'disableInput' | 'value' | string} option - 选项名称 + * @returns {string | boolean | object | null} 选项值 + */ + getOption(option) { + switch (option) { + case 'readOnly': + return this.view.state.facet(EditorState.readOnly); + case 'disableInput': + return this.view.state.facet(EditorState.readOnly); + case 'value': + return this.view.state.doc.toString(); + default: + return null; + } + } + + /** + * 设置搜索查询并高亮匹配 + * @param {string} query - 搜索字符串或正则表达式 + * @param {boolean} [caseSensitive=false] - 是否区分大小写 + * @param {boolean} [isRegex=false] - 是否为正则表达式 + * @returns {void} + */ + setSearchQuery(query, caseSensitive = false, isRegex = false) { + if (!query || query.trim() === '') { + this.clearSearchQuery(); + return; + } + + let searchRe; + if (isRegex) { + try { + searchRe = new RegExp(query, caseSensitive ? 'g' : 'gi'); + } catch (e) { + console.warn('Invalid regex:', e); + return; + } + } else { + const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + searchRe = new RegExp(escaped, caseSensitive ? 'g' : 'gi'); + } + + const plugin = this.view.plugin(searchHighlightField); + if (plugin) { + plugin.query = searchRe; + this.view.dispatch({ + effects: setSearchHighlightEffect.of(Decoration.none), + }); + } + } + + /** + * 清除搜索高亮 + * @returns {void} + */ + clearSearchQuery() { + const plugin = this.view.plugin(searchHighlightField); + if (plugin) { + plugin.query = null; + this.view.dispatch({ + effects: setSearchHighlightEffect.of(Decoration.none), + }); + } + } + + /** + * 标记指定范围的文本 + * @param {number} from - 起始位置(文档偏移量) + * @param {number} to - 结束位置(文档偏移量) + * @param {MarkTextOptions} options - 标记选项 + * @returns {TextMarker} 标记对象 + */ + markText(from, to, options) { + this.markIdCounter += 1; + const markId = `mark_${this.markIdCounter}`; + + const decoration = options.replacedWith + ? Decoration.replace({ + widget: new ReplacementWidget(options.replacedWith), + attributes: { 'data-mark-id': markId }, + }) + : Decoration.mark({ + class: options.className, + attributes: { + ...(options.title ? { title: options.title } : {}), + 'data-mark-id': markId, + }, + }); + + this.view.dispatch({ + effects: addMark.of({ from, to, decoration, options, markId }), + }); + + const { view } = this; + const savedMarkId = markId; + + return { + clear: () => { + view.dispatch({ + effects: removeMark.of({ from, to, markId: savedMarkId }), + }); + }, + find: () => { + const marks = view.state.field(markField, false); + if (!marks) return undefined; + + const iter = marks.iter(); + while (iter.value) { + const attrMarkId = iter.value.spec?.attributes?.['data-mark-id']; + if (attrMarkId === savedMarkId) { + return { from: iter.from, to: iter.to }; + } + iter.next(); + } + + return undefined; + }, + className: options.className, + markId: savedMarkId, + }; + } + + /** + * 查找指定范围内的标记 + * @param {number} from - 起始位置(文档偏移量) + * @param {number} to - 结束位置(文档偏移量) + * @returns {MarkInfo[]} 标记信息数组 + */ + findMarks(from, to) { + const marks = this.view.state.field(markField, false); + if (!marks) return []; + + /** @type {MarkInfo[]} */ + const result = []; + const iter = marks.iter(); + while (iter.value) { + if (iter.from <= to && iter.to >= from) { + result.push({ + from: iter.from, + to: iter.to, + className: iter.value.spec?.class || '', + }); + } + iter.next(); + } + return result; + } + + /** + * 获取搜索游标 + * @param {string | RegExp} query - 搜索字符串或正则表达式 + * @param {number} [pos=0] - 起始搜索位置(文档偏移量) + * @param {boolean} [caseFold] - 是否忽略大小写(true 忽略,false 区分) + * @returns {SearchCursor} 搜索游标对象 + */ + getSearchCursor(query, pos = 0, caseFold) { + let searchStr = typeof query === 'string' ? query : query.source; + let isRegexp = query instanceof RegExp; + + if (isRegexp) { + try { + new RegExp(searchStr, 'gimu'); + } catch (e) { + console.error('Invalid regexp for CodeMirror Search:', searchStr, e.message); + searchStr = '(?!.*)'; + isRegexp = true; + } + } + + const searchQuery = new SearchQuery({ + search: searchStr, + regexp: isRegexp, + caseSensitive: caseFold === false, + }); + + const { doc } = this.view.state; + let cursor = searchQuery.getCursor(doc, pos); + + /** @type {{ from: number; to: number } | null} */ + let lastSearchResult = null; + let currentPos = pos; + + const findPreviousMatch = (/** @type {number} */ fromPos) => { + const prevCursor = searchQuery.getCursor(doc, 0); + let lastMatch = null; + + let result = prevCursor.next(); + while (!result.done && result.value.from < fromPos) { + lastMatch = result.value; + result = prevCursor.next(); + } + + return lastMatch; + }; + + return { + findNext: () => { + const result = cursor.next(); + if (result.done) return false; + + currentPos = result.value.to; + lastSearchResult = result.value; + + const matched = doc.sliceString(result.value.from, result.value.to); + const matchArr = query instanceof RegExp ? matched.match(query) : [matched]; + return matchArr || false; + }, + findPrevious: () => { + const prevMatch = findPreviousMatch(currentPos); + if (!prevMatch) return false; + + currentPos = prevMatch.from; + lastSearchResult = prevMatch; + cursor = searchQuery.getCursor(doc, currentPos); + + const matched = doc.sliceString(prevMatch.from, prevMatch.to); + const matchResult = query instanceof RegExp ? matched.match(query) : [matched]; + return matchResult || false; + }, + from: () => { + if (!lastSearchResult) return null; + return lastSearchResult.from; + }, + to: () => { + if (!lastSearchResult) return null; + return lastSearchResult.to; + }, + matches: (reverse, startPos) => { + if (!lastSearchResult) { + return { from: startPos, to: startPos }; + } + return { from: lastSearchResult.from, to: lastSearchResult.to }; + }, + }; + } + + /** + * 添加事件监听器 + * @param {string} event - 事件名称 + * @param {(...args: unknown[]) => void} handler - 事件处理函数 + * @returns {void} + */ + on(event, handler) { + if (!this.eventHandlers.has(event)) { + this.eventHandlers.set(event, []); + } + this.eventHandlers.get(event).push(handler); + } + + /** + * 移除事件监听器 + * @param {string} event - 事件名称 + * @param {(...args: unknown[]) => void} handler - 事件处理函数 + * @returns {void} + */ + off(event, handler) { + const handlers = this.eventHandlers.get(event); + if (handlers) { + const index = handlers.indexOf(handler); + if (index > -1) { + handlers.splice(index, 1); + } + } + } + + /** + * 触发事件 + * @param {string} event - 事件名称 + * @param {...unknown} args - 事件参数 + * @returns {void} + */ + emit(event, ...args) { + const handlers = this.eventHandlers.get(event); + if (handlers) { + if (event === 'change' && args[0]) { + /** @type {import('@codemirror/view').ViewUpdate} */ + const update = /** @type {import('@codemirror/view').ViewUpdate} */ (args[0]); + if (update.changes) { + let origin; + if (update.transactions.length > 0) { + const tr = update.transactions[0]; + const userEvent = tr.annotation(Transaction.userEvent); + if (userEvent) { + if (userEvent === 'input' || userEvent.startsWith('input.')) origin = '+input'; + else if (userEvent === 'delete' || userEvent.startsWith('delete.')) origin = '+delete'; + else if (userEvent === 'undo' || userEvent.startsWith('undo.')) origin = 'undo'; + else if (userEvent === 'redo' || userEvent.startsWith('redo.')) origin = 'redo'; + } + } + + const changes = []; + update.changes.iterChanges((fromA, toA, _fromB, _toB, inserted) => { + changes.push({ + from: fromA, + to: toA, + text: inserted.toString().split('\n'), + removed: update.startState.doc.sliceString(fromA, toA).split('\n'), + origin, + }); + }); + + const changeObj = + changes.length === 1 + ? changes[0] + : { + from: changes[0]?.from ?? 0, + to: changes[changes.length - 1]?.to ?? 0, + text: changes.flatMap((c) => c.text), + removed: changes.flatMap((c) => c.removed), + origin, + changes, + }; + + handlers.forEach((handler) => handler(this, changeObj)); + } else { + handlers.forEach((handler) => handler(this, ...args)); + } + } else { + handlers.forEach((handler) => handler(this, ...args)); + } + } + } +} + +// 替换 Widget +class ReplacementWidget extends WidgetType { + /** + * @param {HTMLElement} dom - 要替换的 DOM 元素 + */ + constructor(dom) { + super(); + /** @type {HTMLElement} */ + this.dom = dom; + } + + /** + * @returns {HTMLElement} + */ + toDOM() { + return /** @type {HTMLElement} */ (this.dom.cloneNode(true)); + } + + /** + * @param {ReplacementWidget} other - 另一个 Widget 实例 + * @returns {boolean} + */ + eq(other) { + return this.dom === other.dom; + } +} + +// Mark 状态管理 +/** @type {import('@codemirror/state').StateEffectType} */ +const addMark = StateEffect.define(); +/** @type {import('@codemirror/state').StateEffectType<{from: number, to: number, markId?: string}>} */ +const removeMark = StateEffect.define(); + +const markField = StateField.define({ + create() { + return Decoration.none; + }, + update(currentMarks, tr) { + let updatedMarks = currentMarks.map(tr.changes); + + const toAdd = []; + const removeFilters = []; + + for (const effect of tr.effects) { + if (effect.is(addMark) && effect.value) { + const decoration = effect.value.decoration.range(effect.value.from, effect.value.to); + toAdd.push(decoration); + } else if (effect.is(removeMark) && effect.value) { + removeFilters.push(effect.value); + } + } + + if (toAdd.length > 0 || removeFilters.length > 0) { + const removeMarkIdSet = new Set(); + const removeRangeSet = new Set(); + + for (const filter of removeFilters) { + if (filter.markId) { + removeMarkIdSet.add(filter.markId); + } else { + removeRangeSet.add(`${filter.from}_${filter.to}`); + } + } + + if (toAdd.length > 1) { + toAdd.sort((a, b) => a.from - b.from); + } + + updatedMarks = updatedMarks.update({ + add: toAdd, + filter: + removeFilters.length > 0 + ? (from, to, value) => { + const attrMarkId = value.spec?.attributes?.['data-mark-id']; + if (attrMarkId && removeMarkIdSet.has(attrMarkId)) { + return false; + } + if (removeRangeSet.has(`${from}_${to}`)) { + return false; + } + return true; + } + : undefined, + }); + } + + return updatedMarks; + }, + provide: (f) => EditorView.decorations.from(f), +}); /** @type {import('~types/editor')} */ export default class Editor { - /** @type {typeof import('codemirror')} CodeMirror 模块,供其他模块复用 */ - static codemirrorModule = codemirror; - /** * @constructor * @param {Partial} options @@ -71,38 +978,12 @@ export default class Editor { wrapperDom: null, autoScrollByCursor: true, convertWhenPaste: true, - keyMap: 'sublime', + keyMap: 'sublime', // 快捷键风格: sublime | vim showFullWidthMark: true, showSuggestList: true, codemirror: { - lineNumbers: false, // 显示行数 - cursorHeight: 0.85, // 光标高度,0.85好看一些 - indentUnit: 4, // 缩进单位为4 - tabSize: 4, // 一个tab转换成的空格数量 - // styleActiveLine: false, // 当前行背景高亮 - // matchBrackets: true, // 括号匹配 - // mode: 'gfm', // 从markdown模式改成gfm模式,以使用默认高亮规则 - mode: { - name: 'yaml-frontmatter', // yaml-frontmatter在gfm的基础上增加了对yaml的支持 - base: { - name: 'gfm', - gitHubSpice: false, // 修复github风格的markdown语法高亮,见[issue#925](https://github.com/Tencent/cherry-markdown/issues/925) - }, - }, - lineWrapping: true, // 自动换行 - indentWithTabs: true, // 缩进用tab表示 - autofocus: true, - theme: 'default', - autoCloseTags: true, // 输入html标签时自动补充闭合标签 - extraKeys: { - Enter: handleNewlineIndentList, - }, // 增加markdown回车自动补全 - matchTags: { bothTags: true }, // 自动高亮选中的闭合html标签 - placeholder: '', - // 设置为 contenteditable 对输入法定位更友好 - // 但已知会影响某些悬浮菜单的定位,如粘贴选择文本或markdown模式的菜单 - // inputStyle: 'contenteditable', - keyMap: 'sublime', + lineNumbers: false, // 显示行号 + placeholder: '', // 占位符文本 }, toolbars: {}, onKeydown() {}, @@ -112,21 +993,51 @@ export default class Editor { onPaste: this.onPaste, onScroll: this.onScroll, }; - /** - * @property - * @private - * @type {{ timer?: number; destinationTop?: number }} - */ - this.animation = {}; - this.selectAll = false; + /** @type {CM6AdapterType | null} */ + this.editor = null; + + this.animation = { + timer: 0, + destinationTop: 0, + }; + this.disableScrollListener = false; + + /** @type {Array<{elm: Element, evType: string, fn: Function, useCapture: boolean}>} */ + this.domEventListeners = []; + + /** @type {import('@codemirror/view').KeyBinding[]} */ + this.defaultKeymap = []; + /** @type {boolean} */ + this.shortcutDisabled = false; + + /** @type {Compartment} */ + this.keymapCompartment = new Compartment(); + /** @type {Compartment} */ + this.vimCompartment = new Compartment(); + + /** @type {NodeJS.Timeout | number} */ + this.dealSpecialWordsTimer = 0; + /** @type {number} */ + this.dealSpecialWordsStartTime = 0; + + /** @type {boolean} */ + this.isDestroyed = false; + + /** @type {((key: string) => boolean) | null} */ + this.arrowKeyInterceptor = null; + const { codemirror, ...restOptions } = options; if (codemirror) { Object.assign(this.options.codemirror, codemirror); } Object.assign(this.options, restOptions); - this.options.codemirror.keyMap = this.options.keyMap; this.$cherry = this.options.$cherry; - this.instanceId = this.$cherry.getInstanceId(); + } + + refresh() { + if (this.editor) { + this.editor.requestMeasure(); + } } /** @@ -134,10 +1045,21 @@ export default class Editor { * @param {boolean} disable 是否禁用快捷键 */ disableShortcut = (disable = true) => { + if (!this.editor || !this.editor.view) { + return; + } + + const { view } = this.editor; + this.shortcutDisabled = disable; + if (disable) { - this.editor.setOption('keyMap', 'default'); + view.dispatch({ + effects: this.keymapCompartment.reconfigure([]), + }); } else { - this.editor.setOption('keyMap', this.options.keyMap); + view.dispatch({ + effects: this.keymapCompartment.reconfigure(keymap.of(this.defaultKeymap)), + }); } }; @@ -146,232 +1068,493 @@ export default class Editor { * 以及对全角符号进行特殊染色。 */ dealSpecialWords = () => { - /** - * 如果编辑器隐藏了,则不再处理(否则有性能问题) - * - 性能问题出现的原因: - * 1. 纯预览模式下,cherry的高度可能会被设置成auto(也就是没有滚动条) - * 2. 这时候codemirror的高度也是auto,其“视窗懒加载”提升性能的手段就失效了 - * 3. 这时再大量的调用markText等api就会非常耗时 - * - 经过上述分析,最好的判断应该是判断**编辑器高度是否为auto**,但考虑到一般只有纯预览模式才大概率设置成auto,所以就只判断纯预览模式了 - */ - if (this.$cherry.status.editor === 'hide') { - return; + const config = this.options.dealSpecialWordsConfig || {}; + const debounceMs = config.debounceMs ?? 200; + const forceProcessMs = config.forceProcessMs ?? 1000; + + if (this.dealSpecialWordsTimer) { + clearTimeout(this.dealSpecialWordsTimer); } - this.formatBigData2Mark(pasteWrapperReg, 'cm-url paste-wrapper', (target, oneSearch) => { - const whole = oneSearch[0] ?? ''; - const id = oneSearch[1] ?? ''; - const bigString = oneSearch[2] ?? ''; - const targetChFrom = target.ch; - const targetChTo = targetChFrom + whole.length; - const targetLine = target.line; - const begin = { line: targetLine, ch: targetChFrom }; - const end = { line: targetLine, ch: targetChTo }; - return { bigString, begin, end, id }; - }); - /** - * 如果编辑器行数超过10000,则不再处理 - * 增加这个逻辑是为了避免性能问题,当超过1w行时,formatBigData2Mark耗费的性能会明显增加。后续在优化后可以去掉这个降级逻辑 - * 允许降级的理由:超过1w行的md基本已经不关心base64等数据是否缩略展示了 - */ - if (this.editor.lineCount() > 10000) { + if (!this.dealSpecialWordsStartTime) { + this.dealSpecialWordsStartTime = Date.now(); + } + + const timeSinceStart = Date.now() - this.dealSpecialWordsStartTime; + const remainingForceTime = forceProcessMs - timeSinceStart; + const delay = remainingForceTime <= 0 ? 0 : Math.min(debounceMs, remainingForceTime); + + this.dealSpecialWordsTimer = setTimeout(() => { + this.doDealSpecialWordsInternal(); + this.dealSpecialWordsTimer = 0; + this.dealSpecialWordsStartTime = 0; + }, delay); + }; + + /** + * 实际执行特殊词处理的逻辑 + * @private + */ + doDealSpecialWordsInternal = () => { + if (this.$cherry?.status?.editor === 'hide' || this.isDestroyed) { return; } - this.formatBigData2Mark(base64Reg, 'cm-url base64'); - this.formatBigData2Mark(imgDrawioXmlReg, 'cm-url drawio'); - // 长文本替换的正则性能太差,先注释掉 - // this.formatBigData2Mark(longTextReg, 'cm-url long-text'); + + const lineCount = this.editor.view.state.doc.lines; + const largeDocConfig = this.options.largeDocumentConfig || {}; + const lineThreshold = largeDocConfig.lineThreshold ?? 10000; + const strategy = largeDocConfig.strategy ?? 'degrade'; + + if (lineCount > lineThreshold) { + if (strategy === 'skip') { + return; + } + if (strategy === 'degrade') { + return this.doPartialMarkProcessing(); + } + } + + const allMarkItems = []; + const existingMarksSet = this.getExistingMarksSet(); + + // 收集 paste-wrapper 标记 + this.collectMarkItems( + pasteWrapperReg, + 'cm-url paste-wrapper', + allMarkItems, + (fromPos, matchResult) => { + const whole = matchResult[0] ?? ''; + const id = matchResult[1] ?? ''; + const bigString = matchResult[2] ?? ''; + // 验证:ID 和 bigString 不应包含换行符(保持原正则 [^|\n] 的严格性) + if (id.includes('\n') || bigString.includes('\n')) { + return null; + } + const begin = fromPos; + const end = fromPos + whole.length; + return { bigString, begin, end, id }; + }, + existingMarksSet, + ); + + // 收集 base64 标记 + this.collectMarkItems(base64Reg, 'cm-url base64', allMarkItems, undefined, existingMarksSet); + + // 收集 drawio 标记 + this.collectMarkItems(imgDrawioXmlReg, 'cm-url drawio', allMarkItems, undefined, existingMarksSet); + + // 收集 URL 标记 if (this.$cherry.options.editor.maxUrlLength > 10) { const [protocolUrlPattern, wwwUrlPattern] = createUrlReg(this.$cherry.options.editor.maxUrlLength); - this.formatBigData2Mark(protocolUrlPattern, 'cm-url url-truncated'); - this.formatBigData2Mark(wwwUrlPattern, 'cm-url url-truncated'); + this.collectMarkItems(protocolUrlPattern, 'cm-url url-truncated', allMarkItems, undefined, existingMarksSet); + this.collectMarkItems(wwwUrlPattern, 'cm-url url-truncated', allMarkItems, undefined, existingMarksSet); + } + + // 收集全角字符标记 + if (this.options.showFullWidthMark) { + this.collectFullWidthMarkItems(allMarkItems, existingMarksSet); + } + + // 一次性应用所有装饰(单个 Transaction) + if (allMarkItems.length > 0) { + this.applyBatchMarks(this.editor, allMarkItems); } - this.formatFullWidthMark(); }; /** - * 把大字符串变成省略号 - * @param {*} reg 正则 - * @param {*} className 利用codemirror的MarkText生成的新元素的class - * @param {function} getBeginEnd 获取begin和end的函数 - */ - formatBigData2Mark = ( - reg, - className, - getBeginEnd = (target, oneSearch) => { - const bigString = oneSearch[2] ?? ''; - const targetChFrom = target.ch + oneSearch[1]?.length; - const targetChTo = targetChFrom + bigString.length; - const targetLine = target.line; - const begin = { line: targetLine, ch: targetChFrom }; - const end = { line: targetLine, ch: targetChTo }; - const id = ''; - return { bigString, begin, end, id }; - }, - ) => { - const codemirror = this.editor; - const searcher = codemirror.getSearchCursor(reg); + * 大文档降级处理:仅处理高优先级标记,跳过低优先级标记以保证性能 + * @private + */ + doPartialMarkProcessing = () => { + const allMarkItems = []; + const existingMarksSet = this.getExistingMarksSet(); + + // 大文档降级:只处理高优先级标记(paste-wrapper 和 base64) + this.collectMarkItems( + pasteWrapperReg, + 'cm-url paste-wrapper', + allMarkItems, + (fromPos, matchResult) => { + const whole = matchResult[0] ?? ''; + const id = matchResult[1] ?? ''; + const bigString = matchResult[2] ?? ''; + if (id.includes('\n') || bigString.includes('\n')) { + return null; + } + const begin = fromPos; + const end = fromPos + whole.length; + return { bigString, begin, end, id }; + }, + existingMarksSet, + ); + + this.collectMarkItems(base64Reg, 'cm-url base64', allMarkItems, undefined, existingMarksSet); + + if (allMarkItems.length > 0) { + this.applyBatchMarks(this.editor, allMarkItems); + } + }; + + /** + * 一次性收集所有已有标记(避免 O(n²) 检查) + * @returns {Set} 已有标记的键集合,格式为 "from_to_className" + */ + getExistingMarksSet = () => { + const marksSet = new Set(); + const { editor } = this; + const marks = editor.findMarks(0, editor.view.state.doc.length); + + marks.forEach((mark) => { + const key = `${mark.from}_${mark.to}_${mark.className}`; + marksSet.add(key); + }); + + return marksSet; + }; + + /** + * @typedef {Object} MarkRange + * @property {number} begin - 起始位置 + * @property {number} end - 结束位置 + * @property {string} [bigString] - 可选的大字符串(用于标记内容) + * @property {string} [id] - 可选的 ID + */ + + /** + * 收集标记项(不立即应用,用于批量处理) + * @param {RegExp} reg - 正则表达式 + * @param {string} className - CSS 类名 + * @param {Array} targetArray - 目标数组,用于收集标记项 + * @param {(fromPos: number, matchResult: RegExpMatchArray) => MarkRange | null} [callback] - 可选的回调函数 + * @param {Set} [existingMarksSet] - 已有标记集合(用于避免 O(n²) 检查) + */ + collectMarkItems = (reg, className, targetArray, callback, existingMarksSet) => { + const { editor } = this; + const searcher = editor.getSearchCursor(reg); + + for (let matchResult = searcher.findNext(); matchResult !== false; matchResult = searcher.findNext()) { + const item = this.collectMarkItem(editor, searcher, matchResult, className, callback, existingMarksSet); + if (item) { + targetArray.push(item); + } + } + }; + + /** + * 收集全角字符标记项(不立即应用) + * @param {Array} targetArray - 目标数组,用于收集标记项 + * @param {Set} [existingMarksSet] - 已有标记集合(用于避免 O(n²) 检查) + */ + collectFullWidthMarkItems = (targetArray, existingMarksSet) => { + const regex = /[·¥、:"【】()《》]/; + const { editor } = this; + const searcher = editor.getSearchCursor(regex); + + let oneSearch = searcher.findNext(); + for (; oneSearch !== false; oneSearch = searcher.findNext()) { + const fromPos = searcher.from(); + if (fromPos === null) { + continue; + } + + const toPos = fromPos + 1; + const key = `${fromPos}_${toPos}_cm-fullWidth`; + if (!existingMarksSet || !existingMarksSet.has(key)) { + targetArray.push({ + from: fromPos, + to: toPos, + className: 'cm-fullWidth', + options: { + className: 'cm-fullWidth', + title: '按住Ctrl/Cmd点击切换成半角(Hold down Ctrl/Cmd and click to switch to half-width)', + }, + }); + } + } + }; + + /** + * 收集单个匹配结果的数据(不立即创建 mark) + * @param {CM6Adapter} editor - 编辑器实例 + * @param {SearchCursor} searcher - 搜索游标 + * @param {Array} matchResult - 正则匹配结果 + * @param {string} className - CSS 类名 + * @param {Function} [callback] - 可选的回调函数,签名:callback(fromPos: number, matchResult: Array) -> {begin: number, end: number, bigString: string} + * @param {Set} [existingMarksSet] - 已有标记集合(用于避免 O(n²) 检查) + * @returns {import('~types/editor').BatchMarkItem | null} 返回标记数据或 null(如果已存在或无效) + */ + collectMarkItem = (editor, searcher, matchResult, className, callback, existingMarksSet) => { + const fromPos = searcher.from(); + if (fromPos === null) return null; + + const range = this.calculateMarkRange(matchResult, fromPos, callback); + if (!range) return null; + + const key = `${range.begin}_${range.end}_${className}`; + if (existingMarksSet && existingMarksSet.has(key)) return null; + + const newSpan = createElement('span', `cm-string ${className}`, { title: range.bigString }); + newSpan.textContent = range.bigString; + + return { + from: range.begin, + to: range.end, + className, + replacedWith: newSpan, + options: { replacedWith: newSpan, atomic: true }, + }; + }; + + /** + * 批量应用所有装饰(使用单个 Transaction) + * @param {CM6Adapter} editor - 编辑器实例 + * @param {Array} markItems - 标记项数组 + * @returns {void} + */ + applyBatchMarks = (editor, markItems) => { + const effects = []; + const { view } = editor; - let oneSearch = searcher.findNext(); - for (; oneSearch !== false; oneSearch = searcher.findNext()) { - const target = searcher.from(); - if (!target) { - continue; - } - const { bigString, begin, end, id } = getBeginEnd(target, oneSearch); - // 如果所在区域已经有mark了,则不再增加mark - if (codemirror.findMarks(begin, end).length > 0) { - continue; - } - const newSpan = createElement('span', `cm-string ${className}`, { title: bigString, 'data-id': id }); - newSpan.textContent = bigString; - codemirror.markText(begin, end, { replacedWith: newSpan, atomic: true }); + markItems.forEach((item) => { + editor.markIdCounter += 1; + const markId = `mark_${editor.markIdCounter}`; + + const decoration = item.options.replacedWith + ? Decoration.replace({ + widget: new ReplacementWidget(item.options.replacedWith), + attributes: { 'data-mark-id': markId }, + }) + : Decoration.mark({ + class: item.className, + attributes: { 'data-mark-id': markId }, + }); + + effects.push(addMark.of({ from: item.from, to: item.to, decoration, options: item.options, markId })); + }); + + if (effects.length > 0) { + view.dispatch({ effects }); } }; /** - * 高亮全角符号 ·|¥|、|:|“|”|【|】|(|)|《|》 - * full width翻译为全角 + * 计算 mark 范围 + * @param {Array} matchResult - 正则匹配结果 + * @param {number} fromPos - 匹配起始位置 + * @param {Function} [callback] - 可选的回调函数 + * @returns {{begin: number, end: number, bigString: string} | null} */ - formatFullWidthMark() { - if (!this.options.showFullWidthMark) { - return; - } - const { editor } = this; - const regex = /[·¥、:“”【】()《》]/; // 此处以仅匹配单个全角符号 - const searcher = editor.getSearchCursor(regex); - let oneSearch = searcher.findNext(); - // 防止出现错误的mark - editor.getAllMarks().forEach(function (mark) { - if (mark.className === 'cm-fullWidth') { - const range = JSON.parse(JSON.stringify(mark.find())); - const markedText = editor.getRange(range.from, range.to); - if (!regex.test(markedText)) { - mark.clear(); - } - } - }); - for (; oneSearch !== false; oneSearch = searcher.findNext()) { - const target = searcher.from(); - if (!target) { - continue; - } - const from = { line: target.line, ch: target.ch }; - const to = { line: target.line, ch: target.ch + 1 }; - // 当没有标记时再进行标记,判断textMaker的className必须为"cm-fullWidth", - // 因为cm的addon里会引入className: "CodeMirror-composing"的textMaker干扰判断 - const existMarksLength = editor.findMarks(from, to).filter((item) => { - return item.className === 'cm-fullWidth'; - }); - if (existMarksLength.length === 0) { - editor.markText(from, to, { - className: 'cm-fullWidth', - title: '按住Ctrl/Cmd点击切换成半角(Hold down Ctrl/Cmd and click to switch to half-width)', - }); - } + calculateMarkRange = (matchResult, fromPos, callback) => { + if (callback) { + const result = callback(fromPos, matchResult); + if (result?.begin === undefined || result?.end === undefined) return null; + if (result.begin >= result.end) return null; + if (result.begin < 0 || result.end > this.editor.view.state.doc.length) return null; + + return { + begin: result.begin, + end: result.end, + bigString: result.bigString ?? '', + }; } - } + + const bigString = matchResult[2] ?? ''; + const prefixLength = matchResult[1]?.length ?? 0; + const begin = fromPos + prefixLength; + + return { begin, end: begin + bigString.length, bigString }; + }; /** - * - * @param {CodeMirror.Editor} codemirror - * @param {MouseEvent} evt + * 将全角符号转换为半角符号 + * @param {EditorView | CM6AdapterType} editorView - 编辑器实例 + * @param {MouseEvent} evt - 鼠标事件对象 */ - toHalfWidth(codemirror, evt) { + toHalfWidth(editorView, evt) { const { target } = evt; if (!(target instanceof HTMLElement)) { return; } - // 针对windows用户为Ctrl按键,Mac用户为Cmd按键 + // 按住 Ctrl/Cmd 并点击全角字符时触发转换 if (target.classList.contains('cm-fullWidth') && (evt.ctrlKey || evt.metaKey) && evt.buttons === 1) { const rect = target.getBoundingClientRect(); - // 由于是一个字符,所以肯定在一行 - const from = codemirror.coordsChar({ left: rect.left, top: rect.top }); + // 注意:posAtCoords 期望的是视口坐标(clientX/clientY), + // getBoundingClientRect() 返回的 left/top 已经是视口坐标,无需再减去编辑器偏移 + const fromPos = editorView.posAtCoords({ x: rect.left, y: rect.top }); + if (fromPos === null) return; + const line = editorView.state.doc.lineAt(fromPos); + const from = { line: line.number - 1, ch: fromPos - line.from }; const to = { line: from.line, ch: from.ch + 1 }; - codemirror.setSelection(from, to); - codemirror.replaceSelection( - target.innerText - .replace('·', '`') - .replace('¥', '$') - .replace('、', '/') - .replace(':', ':') - .replace('“', '"') - .replace('”', '"') - .replace('【', '[') - .replace('】', ']') - .replace('(', '(') - .replace(')', ')') - .replace('《', '<') - .replace('》', '>'), + const selection = EditorSelection.range( + editorView.state.doc.line(from.line + 1).from + from.ch, + editorView.state.doc.line(to.line + 1).from + to.ch, ); + editorView.dispatch({ + selection, + scrollIntoView: true, + }); + + const replacementText = target.innerText + .replace('·', '`') + .replace('¥', '$') + .replace('、', '/') + .replace(':', ':') + .replace('"', '"') + .replace('"', '"') + .replace('【', '[') + .replace('】', ']') + .replace('(', '(') + .replace(')', ')') + .replace('《', '<') + .replace('》', '>'); + + editorView.dispatch({ + changes: { + from: editorView.state.selection.main.from, + to: editorView.state.selection.main.to, + insert: replacementText, + }, + selection: { anchor: editorView.state.selection.main.from + replacementText.length }, + scrollIntoView: true, + }); } } /** * * @param {KeyboardEvent} e - * @param {CodeMirror.Editor} codemirror + * @param {EditorView} editorView + */ + /** + * 处理键盘弹起事件(keyup),用于高亮预览区对应的行 + * @param {KeyboardEvent} e - 键盘事件对象 + * @param {EditorView} editorView - 编辑器实例 */ - onKeyup = (e, codemirror) => { - const { line: targetLine } = codemirror.getCursor(); - this.previewer.highlightLine(targetLine + 1); + onKeyup = (e, editorView) => { + const pos = editorView.state.selection.main.head; + const line = editorView.state.doc.lineAt(pos).number; + this.previewer.highlightLine(line); }; /** * * @param {ClipboardEvent} e + * @param {CM6AdapterType} editorView */ - onPaste(e) { + onPaste(e, editorView) { let { clipboardData } = e; if (!clipboardData) { ({ clipboardData } = window); } - const needHandlePaste = this.handleThirdPaste(e, clipboardData); + const needHandlePaste = this.handleThirdPaste(e, clipboardData, editorView); if (needHandlePaste) { - this.handlePaste(e, clipboardData); - } - } - - onPasteCallback({ html, htmlText, mdText }) { - // @ts-ignore - const { randomId, _this } = this; - const allMarks = _this.editor.getAllMarks(); - for (let i = 0; i < allMarks.length; i++) { - const mark = allMarks[i]; - const span = mark.widgetNode.querySelector(`.paste-wrapper[data-id="${randomId}"]`); - if (span) { - const { from, to } = mark.find(); - mark.clear(); - _this.editor.setSelection(from, to); - if (mdText) { - _this.editor.replaceSelection(mdText, 'end'); - } else { - _this.formatHtml2MdWhenPaste(null, html, htmlText); - } - break; + this.handlePaste(e, clipboardData, editorView); + } + } + + /** + * 异步粘贴回调处理 + * @param {Object} params - 回调参数 + * @param {string} params.html - HTML 内容 + * @param {string} params.htmlText - 纯文本 HTML + * @param {string} params.mdText - Markdown 文本 + * @param {string} params.randomId - 随机 ID + * @param {CM6AdapterType} editorView - 编辑器视图 + */ + onPasteCallback({ html, htmlText, mdText, randomId }, editorView) { + // 在 CM6 中,我们使用 markField 来存储装饰 + // 查找包含 randomId 的装饰 + const { state } = editorView; + const marks = state.field(markField, false); + if (!marks) return; + + // Bug Fix: 先收集匹配项,避免在遍历中修改状态 + // 这样确保所有匹配项都能被正确处理 + /** @type {Array<{from: number, to: number, markId: string}>} */ + const matchedMarks = []; + + marks.between(0, state.doc.length, (from, to, decoration) => { + const markId = decoration.spec?.attributes?.['data-mark-id']; + if (markId && markId.startsWith('paste-') && markId.includes(randomId)) { + matchedMarks.push({ from, to, markId }); + } + }); + + // 统一处理收集到的匹配项 + for (const { from, to, markId } of matchedMarks) { + if (mdText) { + // Bug Fix: 合并为单个 transaction,保证操作原子性 + // 这样撤销时可以一次性撤销整个粘贴操作 + editorView.dispatch({ + changes: { from, to, insert: mdText }, + effects: removeMark.of({ from, to, markId }), + selection: { anchor: from + mdText.length }, + }); + } else { + // 先移除占位符装饰,同时记录当前位置 + editorView.dispatch({ + effects: removeMark.of({ from, to, markId }), + selection: { anchor: from }, + }); + // Bug Fix: 使用当前选区位置,而不是传入可能已失效的 from/to + // formatHtml2MdWhenPaste 内部会使用 editorView.state.selection.main + this.formatHtml2MdWhenPaste(null, html, htmlText, editorView); } } } /** * 调用第三方的粘贴回调 + * @param {ClipboardEvent} event - 粘贴事件 + * @param {ClipboardEvent['clipboardData']} clipboardData - 剪贴板数据 + * @param {CM6AdapterType} editorView - 编辑器视图 * @returns {boolean} true: 需要继续处理粘贴内容,false: 不需要继续处理粘贴内容 */ - handleThirdPaste(event, clipboardData) { + handleThirdPaste(event, clipboardData, editorView) { // 生成一个随机id,用于有可能的异步回调 - const randomId = `cherry-paste-${Math.random().toString(36).slice(2)}${new Date().getTime()}`; - const onPasteRet = this.$cherry.options.callback.onPaste( - clipboardData, - this.$cherry, - this.onPasteCallback.bind({ randomId, _this: this }), - ); + const randomId = `${Math.random().toString(36).slice(2)}${new Date().getTime()}`; + const markId = `paste-${randomId}`; + + // 创建符合 onPaste 期望的回调函数(接收 string 参数) + // 但我们改为接收对象,所以使用 any 进行转换 + /** @type {any} */ + const asyncCallback = ({ html, htmlText, mdText }) => { + this.onPasteCallback({ html, htmlText, mdText, randomId }, editorView); + }; + + const onPasteRet = this.$cherry.options.callback.onPaste(clipboardData, this.$cherry, asyncCallback); + if (onPasteRet !== false && typeof onPasteRet === 'string') { event.preventDefault(); // 是否命中语法糖 if (/^<<[\s\S]+>>$/.test(onPasteRet)) { const newText = `{{${randomId}|${onPasteRet.replace(/^<<([\s\S]+)>>$/, (whole, $1) => `<<${$1.replace(/[<>]/g, '')}>>`)}}}`; - this.editor.replaceSelection(newText); + const selection = editorView.state.selection.main; + // 创建粘贴占位符 Mark 装饰(范围装饰) + // 注意:Decoration.widget 是点装饰,只应该有一个位置 + // 这里需要高亮整个粘贴范围,所以使用 Decoration.mark + const placeholderMark = Decoration.mark({ + class: 'paste-wrapper', + attributes: { + 'data-mark-id': markId, + 'data-paste-id': `paste-${randomId}`, + }, + }); + editorView.dispatch({ + changes: { from: selection.from, to: selection.to, insert: newText }, + effects: addMark.of({ + from: selection.from, + to: selection.from + newText.length, + decoration: placeholderMark, + }), + selection: { anchor: selection.from + newText.length }, + }); } else { - this.editor.replaceSelection(onPasteRet, 'around'); + // 直接插入内容 + const selection = editorView.state.selection.main; + editorView.dispatch({ + changes: { from: selection.from, to: selection.to, insert: onPasteRet }, + selection: { anchor: selection.from + onPasteRet.length }, + }); } return false; } @@ -382,9 +1565,23 @@ export default class Editor { * * @param {ClipboardEvent} event * @param {ClipboardEvent['clipboardData']} clipboardData + * @param {CM6AdapterType} editorView * @returns {boolean | void} */ - handlePaste(event, clipboardData) { + handlePaste(event, clipboardData, editorView) { + const onPasteRet = this.$cherry.options.callback.onPaste(clipboardData, this.$cherry); + if (onPasteRet !== false && typeof onPasteRet === 'string') { + event.preventDefault(); + // 替换选中内容 + editorView.dispatch({ + changes: { + from: editorView.state.selection.main.from, + to: editorView.state.selection.main.to, + insert: onPasteRet, + }, + }); + return; + } let html = clipboardData.getData('Text/Html'); const { items } = clipboardData; @@ -395,7 +1592,7 @@ export default class Editor { // 删除其他无关的注释 html = html.replace(/