Commit 4646fcd8 by pangchong

feat: 代码编辑新增双击提示功能

parent 77b4b5b1
......@@ -12,10 +12,19 @@
<script setup lang="ts">
import { EditorState, StateField, StateEffect, RangeSetBuilder } from '@codemirror/state'
import { xml } from '@codemirror/lang-xml'
import { autocompletion, CompletionContext, startCompletion, closeBrackets, closeBracketsKeymap, completionKeymap } from '@codemirror/autocomplete'
import {
autocompletion,
CompletionContext,
startCompletion,
closeBrackets,
closeBracketsKeymap,
completionKeymap,
moveCompletionSelection
} from '@codemirror/autocomplete'
import { HighlightStyle, syntaxHighlighting, bracketMatching, foldGutter, foldKeymap, indentOnInput } from '@codemirror/language'
import { tags as t } from '@lezer/highlight'
import { useEditorStore } from '@/store/editor'
import type { DtdContentModel } from '@/types/xmlNode'
import {
EditorView,
Decoration,
......@@ -173,6 +182,7 @@ const editorStyle = computed(() => {
const editorContainerRef = ref<HTMLElement | null>(null)
let editorView: EditorView | null = null
let _hoverObserver: MutationObserver | null = null
const editorStore = useEditorStore()
// 自定义 CodeMirror 高亮样式(完全基于系统 CSS 变量)
......@@ -235,6 +245,14 @@ const customTheme = EditorView.theme({
backgroundColor: 'var(--primary-color, #165DFF)',
color: '#ffffff'
},
'.cm-tooltip.cm-completionInfo': {
maxWidth: '520px !important',
whiteSpace: 'pre-wrap !important',
wordBreak: 'break-word !important',
fontSize: '12px !important',
lineHeight: '1.5 !important',
padding: '6px 10px !important'
},
// 搜索面板自适应深浅主题样式
'.cm-panels': {
backgroundColor: 'var(--colorFill1, #fafafa) !important',
......@@ -378,6 +396,207 @@ const customTheme = EditorView.theme({
}
})
interface ParentInfo {
parentTag: string | null
existingChildren: string[]
}
/**
* 解析 XML 文本在 pos 光标位置之前未闭合的父节点标签名及其已存在的直接子节点列表
*/
function getParentAndExistingChildrenAtPos(docText: string, pos: number): ParentInfo {
const textBefore = docText.slice(0, pos)
const lastLt = textBefore.lastIndexOf('<')
const lastGt = textBefore.lastIndexOf('>')
let parseText = textBefore
if (lastLt !== -1 && lastLt > lastGt) {
parseText = textBefore.slice(0, lastLt)
}
const cleanText = parseText.replace(/<!--[\s\S]*?-->/g, '').replace(/<!\[CDATA\[[\s\S]*?\]\]>/g, '')
const tagRegex = /<(\/)?([a-zA-Z0-9_-]+)(?:\s+[^>]*?)?(\/)?>/g
const stack: { tagName: string; children: string[] }[] = []
let match: RegExpExecArray | null
while ((match = tagRegex.exec(cleanText)) !== null) {
const isClosing = Boolean(match[1])
const tagName = match[2]
const isSelfClosing = Boolean(match[3])
if (isSelfClosing) {
if (stack.length > 0) {
stack[stack.length - 1].children.push(tagName)
}
} else if (isClosing) {
if (stack.length > 0 && stack[stack.length - 1].tagName === tagName) {
const popped = stack.pop()
if (popped && stack.length > 0) {
stack[stack.length - 1].children.push(popped.tagName)
}
} else {
let idx = -1
for (let i = stack.length - 1; i >= 0; i--) {
if (stack[i].tagName === tagName) {
idx = i
break
}
}
if (idx !== -1) {
const poppedList = stack.splice(idx)
const topPopped = poppedList[0]
if (topPopped && stack.length > 0) {
stack[stack.length - 1].children.push(topPopped.tagName)
}
}
}
} else {
stack.push({ tagName, children: [] })
}
}
if (stack.length > 0) {
const top = stack[stack.length - 1]
return {
parentTag: top.tagName,
existingChildren: top.children
}
}
return {
parentTag: null,
existingChildren: []
}
}
/**
* 递归格式化 DtdContentModel 节点,生成完整的非省略 DTD 规则描述
*/
function formatModelToExpression(model?: DtdContentModel | null): string {
if (!model) return ''
const occSuffix = (occ?: string) => {
if (occ === 'optional') return '?'
if (occ === 'zeroOrMore') return '*'
if (occ === 'oneOrMore') return '+'
return ''
}
if (model.type === 'pcdata') {
return '此元素只能包含文本内容'
}
if (model.type === 'empty') {
return '此元素必须为空'
}
if (model.type === 'elementRef' && model.name) {
return model.name + occSuffix(model.occurrence)
}
if ((model.type === 'choice' || model.type === 'sequence') && model.children && model.children.length > 0) {
const sep = model.type === 'choice' ? ' | ' : ', '
const inner = model.children
.map((c) => formatModelToExpression(c))
.filter(Boolean)
.join(sep)
return `(${inner})${occSuffix(model.occurrence)}`
}
if (model.type === 'mixed') {
if (model.children && model.children.length > 0) {
const inner = model.children
.map((c) => formatModelToExpression(c))
.filter(Boolean)
.join(' | ')
return `(#PCDATA | ${inner})*`
}
return '(#PCDATA)'
}
return ''
}
/**
* 获取元素完整无省略的 DTD 约束文本
*/
function getDtdConstraintText(rule: any): string {
if (!rule || !rule.contentModel) return '(#PCDATA)'
const cm = rule.contentModel
if (cm.parsed) {
const formatted = formatModelToExpression(cm.parsed)
if (formatted) return formatted
}
if (cm.raw) {
return cm.raw
}
if (cm.humanReadable) {
return cm.humanReadable.replace(/^高级解析器解析的\w+结构:\s*/, '').replace(/\.\.\.$/, '')
}
return '(#PCDATA)'
}
/**
* 监听补全列表出现,给每个 li 加 mouseenter:
* hover 时把键盘焦点移到该项,右侧 info 面板随之更新,而不会立即 apply。
*/
function setupCompletionHoverInfo(view: EditorView): MutationObserver {
let lastTooltip: Element | null = null
const bindItems = (ul: Element) => {
const items = ul.querySelectorAll<HTMLElement>('li')
items.forEach((li, targetIdx) => {
if ((li as any)._cmHoverBound) return
;(li as any)._cmHoverBound = true
li.addEventListener('mouseenter', () => {
// 找当前已选中项的索引
let currentIdx = -1
items.forEach((el, i) => {
if (el.getAttribute('aria-selected') === 'true') currentIdx = i
})
if (currentIdx === targetIdx) return
const delta = targetIdx - currentIdx
const forward = delta > 0
const cmd = moveCompletionSelection(forward)
for (let i = 0; i < Math.abs(delta); i++) {
cmd(view)
}
})
})
}
const observer = new MutationObserver(() => {
const ul = document.querySelector<Element>('.cm-tooltip-autocomplete ul')
if (ul && ul !== lastTooltip) {
lastTooltip = ul
bindItems(ul)
// 如果列表内容动态增减,再次绑定
new MutationObserver(() => bindItems(ul)).observe(ul, { childList: true })
} else if (!ul) {
lastTooltip = null
}
})
observer.observe(document.body, { childList: true, subtree: true })
return observer
}
// 计算从光标所在 '<' 之后到标签名末尾(空白/'>')的完整区间,防止残留字符导致 ENTRY + TRY = ENTRYTRY 等格式损坏
function getFullTagNameRange(state: EditorState, wordFrom: number): { from: number; to: number } {
const docStr = state.doc.toString()
const textAfterFrom = docStr.slice(wordFrom, Math.min(docStr.length, wordFrom + 200))
// 标签名由 [a-zA-Z0-9_-] 组成,向右扫描到第一个非标签名字符为止
const m = textAfterFrom.match(/^[a-zA-Z0-9_-]*/)
const tagLength = m ? m[0].length : 0
return { from: wordFrom, to: wordFrom + tagLength }
}
// 基于 DTD 的自动补全源
const xmlCompletionSource = (context: CompletionContext) => {
// 1. 如果正在输入标签名称,即匹配当前以 '<' 开头的标签单词部分,但匹配区间从 '<' 之后开始
......@@ -385,37 +604,117 @@ const xmlCompletionSource = (context: CompletionContext) => {
if (wordMatch) {
const charBefore = context.state.sliceDoc(wordMatch.from - 1, wordMatch.from)
if (charBefore === '<') {
const docText = context.state.doc.toString()
const { parentTag, existingChildren } = getParentAndExistingChildrenAtPos(docText, context.pos)
let options: any[] = []
if (parentTag) {
// 1. 如果处于父节点内部(如 <CEP> 内):严格遵循父节点的 DTD 规则及频次上限约束,只允许插入合规子节点
const insertableTags = getInsertableChildren(parentTag, existingChildren)
options = insertableTags.map((tag) => {
const rule = getElementRule(tag)
return {
label: tag,
type: 'type',
detail: `允许在 <${parentTag}> 下`,
info: `XML 标签: ${tag}\nDTD 约束: ${getDtdConstraintText(rule)}`,
apply: (view: EditorView, completion: any, from: number, to: number) => {
const range = getFullTagNameRange(view.state, from)
// 检查标签名后面是否已有 '>'(说明是点击已有标签触发,仅替换标签名本身)
const textAfterTag = view.state.sliceDoc(range.to, Math.min(view.state.doc.length, range.to + 200))
const nextGt = textAfterTag.indexOf('>')
const nextLt = textAfterTag.indexOf('<')
const isExistingTag = nextGt !== -1 && (nextLt === -1 || nextGt < nextLt)
if (isExistingTag) {
// 已有标签:只替换开始标签名,同步更新对应闭合标签
const oldTagName = view.state.sliceDoc(range.from, range.to)
const changes: { from: number; to: number; insert: string }[] = [{ from, to: range.to, insert: tag }]
// 查找并同步更新闭合标签 </oldTag>
if (oldTagName && oldTagName !== tag) {
const docAfter = view.state.sliceDoc(range.to, view.state.doc.length)
const closingStr = `</${oldTagName}>`
const closingIdx = docAfter.indexOf(closingStr)
if (closingIdx !== -1) {
const closingFrom = range.to + closingIdx + 2 // 跳过 </
const closingTo = closingFrom + oldTagName.length
changes.push({ from: closingFrom, to: closingTo, insert: tag })
}
}
view.dispatch({
changes,
selection: { anchor: from + tag.length }
})
} else {
// 新建标签:插入完整结构
const insertText = `${tag}></${tag}>`
view.dispatch({
changes: { from, to: range.to, insert: insertText },
selection: { anchor: from + tag.length + 1 }
})
}
},
boost: 99
}
})
} else {
// 2. 如果处于代码最顶层(无包裹标签):优先将树上当前选中节点允许的子节点排在最前 (✨ 推荐),同时保留全量 DTD 标签供自由选择
const allTags = getAllElementNames()
let recommendedTags: string[] = []
// 获取当前选中节点允许的子节点列表,并提高推荐优先级
let allowedTags: string[] = []
const selectedNodeId = editorStore.selectedNodeId
if (selectedNodeId) {
const mapped = editorStore.nodeMap.get(selectedNodeId)
if (mapped) {
allowedTags = getAllowedChildren(mapped.node.tagName)
const existing = mapped.node.children ? mapped.node.children.map((c) => c.tagName) : []
recommendedTags = getInsertableChildren(mapped.node.tagName, existing)
}
}
const options = allTags.map((tag) => {
const isAllowed = allowedTags.includes(tag)
options = allTags.map((tag) => {
const isRecommended = recommendedTags.includes(tag)
const rule = getElementRule(tag)
return {
label: tag,
type: 'type',
detail: isAllowed ? ' 推荐' : '标签',
info: `XML 标签: ${tag}\nDTD 约束: ${rule?.contentModel.humanReadable || '(#PCDATA)'}`,
// 自动闭合标签并将光标放置在中间
detail: isRecommended ? ' 推荐' : '标签',
info: `XML 标签: ${tag}\nDTD 约束: ${getDtdConstraintText(rule)}`,
apply: (view: EditorView, completion: any, from: number, to: number) => {
const range = getFullTagNameRange(view.state, from)
const textAfterTag = view.state.sliceDoc(range.to, Math.min(view.state.doc.length, range.to + 200))
const nextGt = textAfterTag.indexOf('>')
const nextLt = textAfterTag.indexOf('<')
const isExistingTag = nextGt !== -1 && (nextLt === -1 || nextGt < nextLt)
if (isExistingTag) {
// 已有标签:只替换开始标签名,同步更新对应闭合标签
const oldTagName = view.state.sliceDoc(range.from, range.to)
const changes: { from: number; to: number; insert: string }[] = [{ from, to: range.to, insert: tag }]
if (oldTagName && oldTagName !== tag) {
const docAfter = view.state.sliceDoc(range.to, view.state.doc.length)
const closingStr = `</${oldTagName}>`
const closingIdx = docAfter.indexOf(closingStr)
if (closingIdx !== -1) {
const closingFrom = range.to + closingIdx + 2
const closingTo = closingFrom + oldTagName.length
changes.push({ from: closingFrom, to: closingTo, insert: tag })
}
}
view.dispatch({
changes,
selection: { anchor: from + tag.length }
})
} else {
const insertText = `${tag}></${tag}>`
view.dispatch({
changes: { from, to, insert: insertText },
selection: { anchor: from + tag.length + 2 }
changes: { from, to: range.to, insert: insertText },
selection: { anchor: from + tag.length + 1 }
})
}
},
boost: isAllowed ? 99 : 0
boost: isRecommended ? 99 : 0
}
})
}
return {
from: wordMatch.from,
......@@ -463,7 +762,25 @@ const xmlCompletionSource = (context: CompletionContext) => {
type: 'property',
detail: isRequired ? '🔴 必填' : '可选',
info: `属性: ${name}\n类型: ${def.typeDefinition}\n默认值: ${def.defaultValue || '无'}\n是否必填: ${isRequired ? '是' : '否'}`,
apply: `${name}=""`
apply: (view: EditorView, completion: any, from: number, to: number) => {
// from 是已输入前缀的开头,向右扫描完整的属性名([a-zA-Z0-9_-]+)
const docStr = view.state.doc.toString()
const textFromWordStart = docStr.slice(from, Math.min(docStr.length, from + 300))
// 完整属性名范围
const attrNameEnd = from + textFromWordStart.match(/^[a-zA-Z0-9_-]*/)![0].length
// 检测是否紧跟有 ='...' 或 ="...",若有则一并替换
const afterAttrName = docStr.slice(attrNameEnd, Math.min(docStr.length, attrNameEnd + 200))
const eqValueMatch = afterAttrName.match(/^\s*=\s*(['"])(.*?)\1/)
let replaceEnd = attrNameEnd
if (eqValueMatch) {
replaceEnd = attrNameEnd + eqValueMatch[0].length
}
const insertText = `${name}=""`
view.dispatch({
changes: { from, to: replaceEnd, insert: insertText },
selection: { anchor: from + name.length + 2 } // 光标落在引号内
})
}
}
})
......@@ -502,7 +819,18 @@ const xmlCompletionSource = (context: CompletionContext) => {
label: val,
type: 'value',
detail: '属性值',
apply: val
apply: (view: EditorView, completion: any, from: number, to: number) => {
// 找到当前属性值结尾(闭合引号位置),替换引号内全部内容
const docStr = view.state.doc.toString()
const quoteChar = isDouble ? '"' : "'"
const afterFrom = docStr.slice(from, Math.min(docStr.length, from + 200))
const closingQuoteIdx = afterFrom.indexOf(quoteChar)
const replaceEnd = closingQuoteIdx !== -1 ? from + closingQuoteIdx : to
view.dispatch({
changes: { from, to: replaceEnd, insert: val },
selection: { anchor: from + val.length }
})
}
}
})
......@@ -716,6 +1044,56 @@ const initCodeMirror = () => {
state: startState,
parent: editorContainerRef.value
})
// 启动 hover 预览 observer:hover 到补全项时移动键盘焦点,使右侧 info 面板随之更新
_hoverObserver = setupCompletionHoverInfo(editorView)
// 监听双击:在 mousedown 阶段检测 detail===2(第二次按下即为双击)
editorView.dom.addEventListener('mousedown', (e: MouseEvent) => {
if (e.detail !== 2 || e.button !== 0) return
if (!editorView) return
// 阻止默认双击选词高亮块行为,防止非空选区导致 CodeMirror 自动隐藏补全弹窗
e.preventDefault()
const docText = editorView.state.doc.toString()
const clickPos = editorView.posAtCoords({ x: e.clientX, y: e.clientY }) ?? editorView.state.selection.main.head
const textBeforeClick = docText.slice(Math.max(0, clickPos - 500), clickPos)
const lastLt = textBeforeClick.lastIndexOf('<')
const lastGt = textBeforeClick.lastIndexOf('>')
// 若双击位于 XML 标签内部(< ... >
if (lastLt === -1 || lastLt <= lastGt) return
const tagText = textBeforeClick.substring(lastLt)
// 检查双击点是否在属性区(tagText 包含空格、= 或引号)
const isInsideAttribute = /[\s='"]/.test(tagText)
let checkPos = clickPos
if (isInsideAttribute) {
// 1. 双击属性名/属性值:定位到词首,前缀为空 -> 展示全量候选列表
while (checkPos > 0 && /[a-zA-Z0-9_-]/.test(docText[checkPos - 1])) {
checkPos--
}
} else {
// 2. 双击节点标签名:定位到词尾,带有完整单词前缀 -> 进行完全匹配
while (checkPos < docText.length && /[a-zA-Z0-9_-]/.test(docText[checkPos])) {
checkPos++
}
}
// 折叠光标到目标位置
editorView.dispatch({ selection: { anchor: checkPos } })
editorView.focus()
setTimeout(() => {
if (editorView) {
startCompletion(editorView)
}
}, 20)
})
}
watch(
......@@ -839,6 +1217,7 @@ onMounted(() => {
onBeforeUnmount(() => {
window.removeEventListener('keydown', handleGlobalCtrlF, true)
_hoverObserver?.disconnect()
if (editorView) {
editorView.destroy()
}
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment