feat(editor): 新增 Markdown 源码视图切换

在 Tiptap 编辑器右上角增加 </> 悬浮按钮,支持在富文本与
Markdown 源码视图间双向切换,内容自动同步。getMarkdown() 在
源码模式下返回 textarea 内容,使 Rust 端提交逻辑无需改动。
This commit is contained in:
xfy 2026-06-16 15:50:09 +08:00
parent c6d27f0ae0
commit 480aa92bb4
2 changed files with 137 additions and 0 deletions

View File

@ -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 = ''
}
}

View File

@ -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;
}