Skip to content
Projects
Groups
Snippets
Help
This project
Loading...
Sign in / Register
Toggle navigation
I
Ifar-Xml-Edtor
Overview
Overview
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
pangchong
Ifar-Xml-Edtor
Commits
4646fcd8
Commit
4646fcd8
authored
Jul 29, 2026
by
pangchong
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat: 代码编辑新增双击提示功能
parent
77b4b5b1
Show whitespace changes
Inline
Side-by-side
Showing
1 changed file
with
393 additions
and
14 deletions
+393
-14
src/components/CommonXmlEditor.vue
+393
-14
No files found.
src/components/CommonXmlEditor.vue
View file @
4646fcd8
...
@@ -12,10 +12,19 @@
...
@@ -12,10 +12,19 @@
<
script
setup
lang=
"ts"
>
<
script
setup
lang=
"ts"
>
import
{
EditorState
,
StateField
,
StateEffect
,
RangeSetBuilder
}
from
'@codemirror/state'
import
{
EditorState
,
StateField
,
StateEffect
,
RangeSetBuilder
}
from
'@codemirror/state'
import
{
xml
}
from
'@codemirror/lang-xml'
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
{
HighlightStyle
,
syntaxHighlighting
,
bracketMatching
,
foldGutter
,
foldKeymap
,
indentOnInput
}
from
'@codemirror/language'
import
{
tags
as
t
}
from
'@lezer/highlight'
import
{
tags
as
t
}
from
'@lezer/highlight'
import
{
useEditorStore
}
from
'@/store/editor'
import
{
useEditorStore
}
from
'@/store/editor'
import
type
{
DtdContentModel
}
from
'@/types/xmlNode'
import
{
import
{
EditorView
,
EditorView
,
Decoration
,
Decoration
,
...
@@ -173,6 +182,7 @@ const editorStyle = computed(() => {
...
@@ -173,6 +182,7 @@ const editorStyle = computed(() => {
const
editorContainerRef
=
ref
<
HTMLElement
|
null
>
(
null
)
const
editorContainerRef
=
ref
<
HTMLElement
|
null
>
(
null
)
let
editorView
:
EditorView
|
null
=
null
let
editorView
:
EditorView
|
null
=
null
let
_hoverObserver
:
MutationObserver
|
null
=
null
const
editorStore
=
useEditorStore
()
const
editorStore
=
useEditorStore
()
// 自定义 CodeMirror 高亮样式(完全基于系统 CSS 变量)
// 自定义 CodeMirror 高亮样式(完全基于系统 CSS 变量)
...
@@ -235,6 +245,14 @@ const customTheme = EditorView.theme({
...
@@ -235,6 +245,14 @@ const customTheme = EditorView.theme({
backgroundColor
:
'var(--primary-color, #165DFF)'
,
backgroundColor
:
'var(--primary-color, #165DFF)'
,
color
:
'#ffffff'
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'
:
{
'.cm-panels'
:
{
backgroundColor
:
'var(--colorFill1, #fafafa) !important'
,
backgroundColor
:
'var(--colorFill1, #fafafa) !important'
,
...
@@ -378,6 +396,207 @@ const customTheme = EditorView.theme({
...
@@ -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 的自动补全源
// 基于 DTD 的自动补全源
const xmlCompletionSource = (context: CompletionContext) => {
const xmlCompletionSource = (context: CompletionContext) => {
// 1. 如果正在输入标签名称,即匹配当前以 '
<
' 开头的标签单词部分,但匹配区间从 '
<
' 之后开始
// 1. 如果正在输入标签名称,即匹配当前以 '
<
' 开头的标签单词部分,但匹配区间从 '
<
' 之后开始
...
@@ -385,37 +604,117 @@ const xmlCompletionSource = (context: CompletionContext) => {
...
@@ -385,37 +604,117 @@ const xmlCompletionSource = (context: CompletionContext) => {
if (wordMatch) {
if (wordMatch) {
const charBefore = context.state.sliceDoc(wordMatch.from - 1, wordMatch.from)
const charBefore = context.state.sliceDoc(wordMatch.from - 1, wordMatch.from)
if (charBefore === '
<
') {
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()
const allTags = getAllElementNames()
let recommendedTags: string[] = []
// 获取当前选中节点允许的子节点列表,并提高推荐优先级
let allowedTags: string[] = []
const selectedNodeId = editorStore.selectedNodeId
const selectedNodeId = editorStore.selectedNodeId
if (selectedNodeId) {
if (selectedNodeId) {
const mapped = editorStore.nodeMap.get(selectedNodeId)
const mapped = editorStore.nodeMap.get(selectedNodeId)
if (mapped) {
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) => {
options = allTags.map((tag) => {
const isAllowed = allow
edTags.includes(tag)
const isRecommended = recommend
edTags.includes(tag)
const rule = getElementRule(tag)
const rule = getElementRule(tag)
return {
return {
label: tag,
label: tag,
type: '
type
',
type: '
type
',
detail: isAllowed ? '
✨
推荐
' : '
标签
',
detail: isRecommended ? '
✨
推荐
' : '
标签
',
info: `XML 标签: ${tag}
\
nDTD 约束: ${rule?.contentModel.humanReadable || '
(
#
PCDATA
)
'}`,
info: `XML 标签: ${tag}
\
nDTD 约束: ${getDtdConstraintText(rule)}`,
// 自动闭合标签并将光标放置在中间
apply: (view: EditorView, completion: any, from: number, to: number) => {
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}>`
const insertText = `${tag}></${tag}>`
view.dispatch({
view.dispatch({
changes: { from,
to, insert: insertText },
changes: { from, to: range.
to, insert: insertText },
selection: { anchor: from + tag.length + 2
}
selection: { anchor: from + tag.length + 1
}
})
})
}
},
},
boost: isAllow
ed ? 99 : 0
boost: isRecommend
ed ? 99 : 0
}
}
})
})
}
return {
return {
from: wordMatch.from,
from: wordMatch.from,
...
@@ -463,7 +762,25 @@ const xmlCompletionSource = (context: CompletionContext) => {
...
@@ -463,7 +762,25 @@ const xmlCompletionSource = (context: CompletionContext) => {
type
:
'property'
,
type
:
'property'
,
detail
:
isRequired
?
'🔴 必填'
:
'可选'
,
detail
:
isRequired
?
'🔴 必填'
:
'可选'
,
info
:
`属性:
${
name
}
\n类型:
${
def
.
typeDefinition
}
\n默认值:
${
def
.
defaultValue
||
'无'
}
\n是否必填:
${
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) => {
...
@@ -502,7 +819,18 @@ const xmlCompletionSource = (context: CompletionContext) => {
label
:
val
,
label
:
val
,
type
:
'value'
,
type
:
'value'
,
detail
:
'属性值'
,
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 = () => {
...
@@ -716,6 +1044,56 @@ const initCodeMirror = () => {
state
:
startState
,
state
:
startState
,
parent
:
editorContainerRef
.
value
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
(
watch
(
...
@@ -839,6 +1217,7 @@ onMounted(() => {
...
@@ -839,6 +1217,7 @@ onMounted(() => {
onBeforeUnmount
(()
=>
{
onBeforeUnmount
(()
=>
{
window
.
removeEventListener
(
'keydown'
,
handleGlobalCtrlF
,
true
)
window
.
removeEventListener
(
'keydown'
,
handleGlobalCtrlF
,
true
)
_hoverObserver
?.
disconnect
()
if
(
editorView
)
{
if
(
editorView
)
{
editorView
.
destroy
()
editorView
.
destroy
()
}
}
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment