From 480aa92bb4be3b98a36d284c28ee45dd7df1aa62 Mon Sep 17 00:00:00 2001 From: xfy Date: Tue, 16 Jun 2026 15:50:09 +0800 Subject: [PATCH] =?UTF-8?q?feat(editor):=20=E6=96=B0=E5=A2=9E=20Markdown?= =?UTF-8?q?=20=E6=BA=90=E7=A0=81=E8=A7=86=E5=9B=BE=E5=88=87=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在 Tiptap 编辑器右上角增加 悬浮按钮,支持在富文本与 Markdown 源码视图间双向切换,内容自动同步。getMarkdown() 在 源码模式下返回 textarea 内容,使 Rust 端提交逻辑无需改动。 --- libs/tiptap-editor/src/index.ts | 67 ++++++++++++++++++++++++++++++ libs/tiptap-editor/src/style.css | 70 ++++++++++++++++++++++++++++++++ 2 files changed, 137 insertions(+) diff --git a/libs/tiptap-editor/src/index.ts b/libs/tiptap-editor/src/index.ts index 5d2fde9..dd7acf5 100644 --- a/libs/tiptap-editor/src/index.ts +++ b/libs/tiptap-editor/src/index.ts @@ -23,6 +23,10 @@ class TiptapEditorInstance { private editor: Editor | null = null private container: HTMLElement private options: EditorOptions + // 源码模式相关状态 + private isSourceMode = false + private sourceTextarea: HTMLTextAreaElement | null = null + private toggleButton: HTMLButtonElement | null = null constructor(container: HTMLElement, options: EditorOptions = {}) { this.container = container @@ -35,6 +39,30 @@ class TiptapEditorInstance { el.className = 'tiptap-editor' this.container.appendChild(el) + // 源码模式切换按钮:悬浮于编辑器右上角 + this.toggleButton = document.createElement('button') + this.toggleButton.className = 'tiptap-toggle-btn' + this.toggleButton.type = 'button' + this.toggleButton.title = '切换 Markdown 源码' + this.toggleButton.textContent = '' + this.toggleButton.addEventListener('click', () => this.toggleSource()) + el.appendChild(this.toggleButton) + + // 源码模式 textarea:初始隐藏,与 ProseMirror 共用同一区域 + this.sourceTextarea = document.createElement('textarea') + this.sourceTextarea.className = 'tiptap-source-textarea' + this.sourceTextarea.hidden = true + this.sourceTextarea.placeholder = '在此输入 Markdown 源码...' + this.sourceTextarea.spellcheck = false + this.sourceTextarea.addEventListener('input', () => { + // 保持全局缓存与 onUpdate 回调一致 + window.__tiptap_content = this.sourceTextarea!.value + if (this.options.onUpdate) { + this.options.onUpdate(this.sourceTextarea!.value) + } + }) + el.appendChild(this.sourceTextarea) + this.editor = new Editor({ element: el, extensions: [ @@ -114,9 +142,44 @@ class TiptapEditorInstance { } getMarkdown(): string { + // 源码模式下直接返回 textarea 内容,确保提交逻辑无需感知视图模式 + if (this.isSourceMode && this.sourceTextarea) { + return this.sourceTextarea.value + } return this.editor?.getMarkdown() || '' } + /** + * 在富文本模式与 Markdown 源码模式之间切换。 + * 切换时同步内容:富文本 → 源码用 getMarkdown 导出;源码 → 富文本用 setMarkdown 回填。 + */ + toggleSource(): void { + if (!this.editor || !this.sourceTextarea || !this.toggleButton) return + + // ProseMirror 的实际 DOM 节点(.ProseMirror),用于切换显隐。 + const proseMirrorDom = this.editor.view.dom + if (!this.isSourceMode) { + // 富文本 → 源码:导出当前 Markdown 到 textarea + this.sourceTextarea.value = this.editor.getMarkdown() + proseMirrorDom.style.display = 'none' + this.sourceTextarea.hidden = false + this.sourceTextarea.focus() + this.toggleButton.textContent = '✎' + this.toggleButton.title = '切换富文本' + this.isSourceMode = true + } else { + // 源码 → 富文本:把 textarea 内容回填到编辑器 + const md = this.sourceTextarea.value + this.setMarkdown(md) + this.sourceTextarea.hidden = true + proseMirrorDom.style.display = '' + this.toggleButton.textContent = '' + this.toggleButton.title = '切换 Markdown 源码' + this.isSourceMode = false + this.editor.commands.focus() + } + } + setMarkdown(content: string): void { this.editor?.commands.setContent(content, { emitUpdate: false, contentType: 'markdown' }) } @@ -140,6 +203,10 @@ class TiptapEditorInstance { destroy(): void { this.editor?.destroy() this.editor = null + // 清理源码模式相关引用(容器 innerHTML 已清空,DOM 会随之移除) + this.sourceTextarea = null + this.toggleButton = null + this.isSourceMode = false this.container.innerHTML = '' } } diff --git a/libs/tiptap-editor/src/style.css b/libs/tiptap-editor/src/style.css index 3b74901..f5951eb 100644 --- a/libs/tiptap-editor/src/style.css +++ b/libs/tiptap-editor/src/style.css @@ -425,3 +425,73 @@ .dark .tiptap-editor ul[data-type="taskList"] li > label input[type="checkbox"] { accent-color: #58a6ff; } + +/* ========== Source Toggle Button ========== */ +.tiptap-toggle-btn { + position: absolute; + top: 12px; + right: 12px; + z-index: 10; + width: 30px; + height: 30px; + display: flex; + align-items: center; + justify-content: center; + border: none; + border-radius: 6px; + background: rgba(0, 0, 0, 0.04); + color: #6a737d; + font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, monospace; + font-size: 13px; + font-weight: 600; + line-height: 1; + cursor: pointer; + opacity: 0.55; + transition: opacity 0.15s, background 0.15s; +} + +.tiptap-toggle-btn:hover { + opacity: 1; + background: rgba(0, 0, 0, 0.08); +} + +.dark .tiptap-toggle-btn { + background: rgba(255, 255, 255, 0.06); + color: #9b9c9d; +} + +.dark .tiptap-toggle-btn:hover { + background: rgba(255, 255, 255, 0.12); +} + +/* ========== Source Textarea ========== */ +.tiptap-source-textarea { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + padding: 24px 32px; + box-sizing: border-box; + border: none; + background: transparent; + color: #2c2c2c; + font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, monospace; + font-size: 14px; + line-height: 1.7; + resize: none; + outline: none; + overflow-y: auto; + z-index: 5; +} + +.tiptap-source-textarea::placeholder { + color: #999; +} + +.dark .tiptap-source-textarea { + color: #dadadb; +} + +.dark .tiptap-source-textarea::placeholder { + color: #666; +}