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:
@@ -1,5 +1,14 @@
|
||||
<template>
|
||||
<div class="editor-wrapper" ref="wrapperRef">
|
||||
<!-- Ghost Text 建议覆盖层 -->
|
||||
<GhostTextOverlay
|
||||
v-if="suggestion"
|
||||
:suggestion="suggestion"
|
||||
:position="suggestionPosition"
|
||||
@accept="acceptSuggestion"
|
||||
@dismiss="dismissSuggestion"
|
||||
/>
|
||||
|
||||
<!-- 单栏编辑器 -->
|
||||
<div
|
||||
ref="editorRef"
|
||||
@@ -44,9 +53,12 @@
|
||||
import { ref, watch, onMounted, nextTick } from 'vue'
|
||||
import { plugins } from '../plugins/index'
|
||||
import PluginHost from './PluginHost.vue'
|
||||
import GhostTextOverlay from './GhostTextOverlay.vue'
|
||||
import markdownIt from 'markdown-it'
|
||||
import Prism from 'prismjs'
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'
|
||||
|
||||
const emit = defineEmits(['update:html'])
|
||||
|
||||
/* ---------- 插件挂载点 ---------- */
|
||||
@@ -83,22 +95,88 @@ const editingCodeBlock = ref(false)
|
||||
const codeBlockContent = ref('')
|
||||
const currentCodeElement = ref(null)
|
||||
|
||||
/* ---------- Ghost Text 建议 ---------- */
|
||||
const suggestion = ref('')
|
||||
const suggestionPosition = ref({ left: 0, top: 0 })
|
||||
let completionController = 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() {
|
||||
const sel = window.getSelection()
|
||||
if (!sel.rangeCount) return null
|
||||
const range = sel.getRangeAt(0)
|
||||
|
||||
// 检查是否在代码块内
|
||||
const codeBlock = range.closest('pre')
|
||||
if (codeBlock) {
|
||||
return { type: 'code', element: codeBlock }
|
||||
try {
|
||||
const range = sel.getRangeAt(0)
|
||||
if (!range) return null
|
||||
|
||||
// 检查是否在代码块内
|
||||
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) {
|
||||
@@ -117,6 +195,83 @@ function restoreSelection(saved) {
|
||||
} 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() {
|
||||
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 获取纯文本 ---------- */
|
||||
function getPlainText() {
|
||||
|
||||
Reference in New Issue
Block a user