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>
|
<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() {
|
||||||
|
|||||||
Reference in New Issue
Block a user