feat(editor): add inline autocomplete suggestions

Add GhostTextOverlay component for displaying AI-powered completion
suggestions while typing. Includes accept and dismiss actions triggered
by API calls to /v1/completions endpoint with prefix/suffix context.
This commit is contained in:
2026-02-07 11:46:03 +08:00
parent 2abf276d10
commit 370510cc50
+162 -19
View File
@@ -1,5 +1,14 @@
<template> <template>
<div class="editor-wrapper" ref="wrapperRef"> <div class="editor-wrapper" ref="wrapperRef">
<!-- Ghost Text 建议覆盖层 -->
<GhostTextOverlay
v-if="suggestion"
:suggestion="suggestion"
:position="suggestionPosition"
@accept="acceptSuggestion"
@dismiss="dismissSuggestion"
/>
<!-- 单栏编辑器 --> <!-- 单栏编辑器 -->
<div <div
ref="editorRef" ref="editorRef"
@@ -44,9 +53,12 @@
import { ref, watch, onMounted, nextTick } from 'vue' import { ref, watch, onMounted, nextTick } from 'vue'
import { plugins } from '../plugins/index' import { plugins } from '../plugins/index'
import PluginHost from './PluginHost.vue' import PluginHost from './PluginHost.vue'
import GhostTextOverlay from './GhostTextOverlay.vue'
import markdownIt from 'markdown-it' import markdownIt from 'markdown-it'
import Prism from 'prismjs' import Prism from 'prismjs'
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'
const emit = defineEmits(['update:html']) const emit = defineEmits(['update:html'])
/* ---------- 插件挂载点 ---------- */ /* ---------- 插件挂载点 ---------- */
@@ -83,22 +95,88 @@ const editingCodeBlock = ref(false)
const codeBlockContent = ref('') const codeBlockContent = ref('')
const currentCodeElement = ref(null) const currentCodeElement = ref(null)
/* ---------- Ghost Text 建议 ---------- */
const suggestion = ref('')
const suggestionPosition = ref({ left: 0, top: 0 })
let completionController = null
/* ---------- 图片预览 ---------- */ /* ---------- 图片预览 ---------- */
const expandedImage = ref(null) const expandedImage = ref(null)
/* ---------- 光标位置管理 ---------- */ /* ---------- 光标位置管理 ---------- */
function getCursorPosition() {
const sel = window.getSelection()
if (!sel.rangeCount || !editorRef.value) return null
try {
const range = sel.getRangeAt(0)
if (!range) return null
// 检查是否在代码块内(使用 commonAncestorContainer.closest
const container = range.commonAncestorContainer
const codeBlock = container.nodeType === Node.ELEMENT_NODE
? container.closest('pre')
: container.parentElement?.closest('pre')
if (codeBlock) {
return { type: 'code', element: codeBlock }
}
// 获取光标位置的坐标
const rect = range.getBoundingClientRect()
const editorRect = editorRef.value.getBoundingClientRect()
return {
type: 'text',
range: range.cloneRange(),
left: rect.left - editorRect.left,
top: rect.top - editorRect.top + window.scrollY
}
} catch (e) {
console.error('getCursorPosition error:', e)
return null
}
}
function getPrefixSuffix(cursorRange) {
const fullText = markdown.value
if (!cursorRange || !cursorRange.range) return { prefix: fullText, suffix: '' }
// 获取光标在纯文本中的位置
const preCaretRange = cursorRange.range.cloneRange()
preCaretRange.selectNodeContents(editorRef.value)
preCaretRange.setEnd(cursorRange.range.startContainer, cursorRange.range.startOffset)
const prefix = preCaretRange.toString()
// 获取 suffix(光标后的内容)
const postCaretRange = cursorRange.range.cloneRange()
postCaretRange.setStartAfter(cursorRange.range.endContainer, cursorRange.range.endOffset)
const suffix = postCaretRange.toString()
return { prefix, suffix }
}
function saveSelection() { function saveSelection() {
const sel = window.getSelection() const sel = window.getSelection()
if (!sel.rangeCount) return null if (!sel.rangeCount) return null
const range = sel.getRangeAt(0)
// 检查是否在代码块内 try {
const codeBlock = range.closest('pre') const range = sel.getRangeAt(0)
if (codeBlock) { if (!range) return null
return { type: 'code', element: codeBlock }
// 检查是否在代码块内
const container = range.commonAncestorContainer
const codeBlock = container.nodeType === Node.ELEMENT_NODE
? container.closest('pre')
: container.parentElement?.closest('pre')
if (codeBlock) {
return { type: 'code', element: codeBlock }
}
return { type: 'text', range: range.cloneRange() }
} catch (e) {
console.error('saveSelection error:', e)
return null
} }
return { type: 'text', range: range.cloneRange() }
} }
function restoreSelection(saved) { function restoreSelection(saved) {
@@ -117,6 +195,83 @@ function restoreSelection(saved) {
} catch (e) {} } catch (e) {}
} }
/* ---------- Ghost Text 请求 ---------- */
async function requestCompletion(prefix, suffix) {
if (completionController) {
completionController.abort()
}
try {
completionController = new AbortController()
const response = await fetch(`${API_URL}/v1/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prefix, suffix, languageId: 'markdown' }),
signal: completionController.signal
})
if (!response.ok) throw new Error('Completion request failed')
const reader = response.body.getReader()
const decoder = new TextDecoder()
let content = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
const chunk = decoder.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) content += data.content
if (data.done) break
} catch {}
}
}
return content
} catch (e) {
if (e.name !== 'AbortError') console.error('Completion error:', e)
return ''
}
}
async function onInput(e) {
isEditing.value = true
const content = getPlainText()
markdown.value = content
clearTimeout(debounceTimer)
debounceTimer = setTimeout(async () => {
renderMarkdown()
// 请求补全建议
const cursorPos = getCursorPosition()
if (cursorPos && cursorPos.type === 'text') {
const { prefix, suffix } = getPrefixSuffix(cursorPos)
suggestion.value = await requestCompletion(prefix, suffix)
if (suggestion.value) {
suggestionPosition.value = { left: cursorPos.left, top: cursorPos.top }
}
}
isEditing.value = false
}, 300)
}
function acceptSuggestion() {
if (!suggestion.value) return
insertAtCursor(suggestion.value)
suggestion.value = ''
onInput()
}
function dismissSuggestion() {
suggestion.value = ''
}
/* ---------- 防抖渲染 ---------- */ /* ---------- 防抖渲染 ---------- */
function renderMarkdown() { function renderMarkdown() {
let html = md.render(markdown.value) let html = md.render(markdown.value)
@@ -145,18 +300,6 @@ function renderMarkdown() {
}) })
} }
function onInput(e) {
isEditing.value = true
// 获取纯文本内容(排除 HTML 标签)
const content = getPlainText()
markdown.value = content
clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => {
renderMarkdown()
isEditing.value = false
}, 300)
}
/* ---------- 从 contenteditable 获取纯文本 ---------- */ /* ---------- 从 contenteditable 获取纯文本 ---------- */
function getPlainText() { function getPlainText() {