Files
llm-in-text/plans/milkdown-editor-plan.md
T
“ydy0615” 2432e78fe1 feat(editor): add file upload and fix critical bugs
- Add markdown file upload functionality with upload button
- Fix error handling to throw errors instead of silently returning empty strings
- Fix memory leak by cleaning up debounceTimer in onUnmounted
- Update debounce timing from 150ms to 500ms for stability
- Enhance UI with floating action buttons and extensive style refinements
- Hide toolbar, menu, and line number elements for cleaner interface
2026-02-12 08:55:37 +08:00

397 lines
9.8 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Milkdown 全屏 WYSIWYG Markdown 编辑器实施方案
## 项目概述
基于 `@milkdown/crepe` 实现全屏覆盖的所见即所得 Markdown 编辑器,替换现有的 contenteditable 编辑器。
## 技术选型
- **核心编辑器**: `@milkdown/crepe` - 功能完整的 WYSIWYG Markdown 编辑器
- **Vue 集成**: `@milkdown/vue` - Vue 3 组件支持
- **主题**: `frame`(简洁框架主题)
## 系统架构
```mermaid
graph TB
A[App.vue] --> B[MilkdownProvider]
B --> C[Milkdown Editor - Crepe]
subgraph "Crepe 核心功能"
D[WYSWIYG 编辑体验]
E[Markdown 语法即时渲染]
F[斜杠命令菜单 Slash Commands]
G[代码块高亮]
H[图片粘贴支持]
end
subgraph "集成功能"
I[GhostTextOverlay<br/>建议文本显示]
J[InlineSuggestionPlugin<br/>智能补全]
end
C --> I
C --> J
```
## 实施步骤
### Step 1: 安装依赖包
```bash
npm install @milkdown/crepe @milkdown/vue
```
### Step 2: 创建 Milkdown 编辑器组件
**文件**: `src/components/MilkdownEditor.vue`
#### 核心实现要点
```vue
<template>
<div class="editor-container" ref="containerRef">
<button class="export-btn" @click="exportMarkdown">导出文件</button>
<div ref="root" class="milkdown-editor"></div>
<!-- 修复正确的组件标签语法 -->
<GhostTextOverlay
v-if="suggestion && cursorRect"
:suggestion="suggestion"
:position="cursorRect"
@accept="acceptSuggestion"
@dismiss="dismissSuggestion"
/>
</div>
</template>
<script setup>
import { onMounted, onUnmounted, ref } from 'vue'
import { Crepe } from '@milkdown/crepe'
import GhostTextOverlay from './GhostTextOverlay.vue'
const root = ref(null)
const containerRef = ref(null)
let crepe = null
const suggestion = ref('')
const cursorRect = ref(null)
let debounceTimer = null
let lastPos = -1
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000/v1/completions'
const DEBOUNCE_MS = 500
onMounted(async () => {
if (!root.value) return
crepe = new Crepe({
root: root.value,
defaultValue: '# Welcome to LLM in text\n\nStart writing your content here...',
})
await crepe.create()
// 修复:使用更可靠的事件绑定方式
initEditorEvents()
})
// 修复:组件卸载时清理资源
onUnmounted(() => {
if (debounceTimer) {
clearTimeout(debounceTimer)
}
if (crepe) {
crepe.destroy()
}
})
const getCursorPosition = async () => {
if (!crepe) return null
try {
const ctx = crepe.ctx.get()
const view = ctx.get('view')
const { from } = view.state.selection
const coords = view.coordsAtPos(from)
const containerRect = containerRef.value?.getBoundingClientRect()
if (!containerRect) return null
return {
left: coords.left - containerRect.left,
top: coords.top - containerRect.top + window.scrollY,
fontSize: 16,
fontFamily: 'monospace',
}
} catch (e) {
console.error('getCursorPosition error:', e)
return null
}
}
const fetchSuggestion = async (prefix, suffix) => {
try {
const res = await fetch(API_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prefix, suffix, languageId: 'markdown' }),
})
// 修复:遵循"获取失败直接报错"原则
if (!res.ok) {
const errorText = await res.text()
throw new Error(`API request failed: ${res.status} - ${errorText}`)
}
const reader = res.body?.getReader()
if (!reader) {
throw new Error('No response body reader available')
}
let text = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
const chunk = new TextDecoder().decode(value)
const lines = chunk.split('\n').filter(l => l.startsWith('data: '))
for (const line of lines) {
try {
const data = JSON.parse(line.slice(6))
if (data.content) {
text += data.content
}
if (data.done || data.error) break
} catch (e) {
// 忽略 JSON 解析错误,继续处理下一行
}
}
}
return text
} catch (e) {
// 修复:直接抛出错误,不返回空字符串
throw e
}
}
const onInput = async () => {
if (!crepe) return
try {
const ctx = crepe.ctx.get()
const view = ctx.get('view')
const { from } = view.state.selection
if (from === lastPos) return
lastPos = from
const prefix = view.state.doc.textBetween(0, from)
const suffix = view.state.doc.textBetween(from, view.state.doc.content.size)
// 修复:使用正确的字符串截取方法
console.log('Prefix preview:', prefix.slice(-50))
clearTimeout(debounceTimer)
debounceTimer = setTimeout(async () => {
try {
cursorRect.value = await getCursorPosition()
suggestion.value = await fetchSuggestion(prefix, suffix)
} catch (e) {
console.error('Failed to fetch suggestion:', e)
suggestion.value = ''
}
}, DEBOUNCE_MS)
} catch (e) {
console.error('onInput error:', e)
}
}
const handleTab = () => {
if (suggestion.value) {
const ctx = crepe.ctx.get()
const view = ctx.get('view')
view.dispatch(view.state.tr.insertText(suggestion.value))
suggestion.value = ''
}
}
const dismissSuggestion = () => {
suggestion.value = ''
}
const acceptSuggestion = () => {
if (suggestion.value) {
const ctx = crepe.ctx.get()
const view = ctx.get('view')
view.dispatch(view.state.tr.insertText(suggestion.value))
suggestion.value = ''
}
}
const exportMarkdown = async () => {
if (!crepe) return
const markdown = await crepe.getMarkdown()
const blob = new Blob([markdown], { type: 'text/markdown' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `document-${Date.now()}.md`
a.click()
URL.revokeObjectURL(url)
}
// 修复:使用更可靠的事件绑定方式
const initEditorEvents = () => {
if (!crepe) return
try {
const ctx = crepe.ctx.get()
const view = ctx.get('view')
// 直接在编辑器 DOM 上监听输入事件
view.dom.addEventListener('input', onInput)
view.dom.addEventListener('keydown', (e) => {
if (e.key === 'Tab') {
handleTab()
}
})
} catch (e) {
console.error('Failed to bind events:', e)
}
}
</script>
<style scoped>
.editor-container {
position: relative;
}
.export-btn {
position: fixed;
top: 20px;
right: 20px;
padding: 8px 16px;
background-color: #4a90d9;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
z-index: 1000;
}
.export-btn:hover {
background-color: #3a7bc8;
}
.milkdown-editor {
width: 100vw;
height: 100vh;
background-color: #ffffff;
overflow-y: auto;
}
.milkdown-editor::-webkit-scrollbar {
width: 8px;
}
.milkdown-editor::-webkit-scrollbar-track {
background: transparent;
}
.milkdown-editor::-webkit-scrollbar-thumb {
background-color: #ddd;
border-radius: 4px;
}
.milkdown-editor :deep(.milkdown) {
max-width: 900px;
margin: 0 auto !important;
padding: 20px 40px !important;
min-height: calc(100vh - 40px);
}
.milkdown-editor :deep(*) {
margin-top: 0 !important;
margin-bottom: 0 !important;
padding-top: 0 !important;
padding-bottom: 0 !important;
}
</style>
```
### Step 3: 更新 App.vue
- 移除旧的 `MarkdownEditor` 组件引用
- 使用新的 `MilkdownEditor` 组件
- 保持全屏布局(100vh × 100vw
### Step 4: 添加样式配置
`main.js` 中导入 Crepe 主题:
```js
import '@milkdown/crepe/theme/common/style.css'
import '@milkdown/crepe/theme/frame.css'
```
## 已知问题及修复方案
### 🔴 严重问题(P0
~~1. 模板语法错误~~ ✅ 已修复
- GhostTextOverlay 组件标签已正确使用尖括号
~~2. 字符串截取错误~~ ✅ 已修复
- 代码使用 `slice()` 而非 `substring(-50)`
~~3. 错误处理违反原则~~ ✅ 已修复
- 获取失败时会抛出错误而非返回空字符串
### 🟡 中等问题(P1
~~4. 内存泄漏风险~~ ✅ 已修复
- 组件卸载时已清理 debounceTimer
~~5. 不可靠的事件绑定~~ ✅ 已修复
- 使用 500ms 防抖机制
6. 代码重复
- fetchSuggestion 逻辑在两个文件中重复
7. 全局状态污染
- 插件使用模块级全局变量
### 🟢 轻微问题(P2
8. 大量调试日志影响性能
9. 缺少完整的类型定义
10. 没有加载状态指示器
11. 建议文本无长度限制
12. API URL 硬编码在前端
13. 后端缺少 CORS 配置
## 全屏覆盖样式要点
- 编辑器容器: `width: 100vw; height: 100vh`
- 移除默认 padding/margin
- 纯编辑器模式,无预览面板
- 自定义滚动条样式
## 性能优化建议
1. **防抖优化**: 保持 500ms 防抖,避免频繁请求
2. **流式响应**: 使用 SSE 流式传输,降低延迟
3. **上下文截取**: 智能截取上下文(光标前30行 + 后5行)
4. **内存管理**: 及时清理定时器和事件监听器
5. **代码精简**: 移除冗余代码和注释
## 测试要点
1. 编辑器基本功能测试
2. 建议功能测试(Tab 接受、Esc 取消、点击接受)
3. 错误处理测试(网络错误、API 错误)
4. 性能测试(大量文本输入)
5. 内存泄漏测试(长时间使用)