dsh-composer-ease 0.0.0-stage → 0.9.66
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +180 -0
- package/LICENSE +21 -0
- package/README.i18n.yaml +30 -0
- package/README.md +79 -2
- package/README.zh.md +80 -0
- package/THIRD-PARTY.md +259 -0
- package/cordis.patch.yml +11 -0
- package/lib/cjk-domain.js +24 -0
- package/lib/cjk-lexicon.js +30049 -0
- package/lib/client/10-foundation.js +396 -0
- package/lib/client/20-pairing.js +177 -0
- package/lib/client/30-popup.js +320 -0
- package/lib/client/40-language.js +174 -0
- package/lib/client/45-settings.js +225 -0
- package/lib/client/50-caret.js +254 -0
- package/lib/client/60-keys.js +242 -0
- package/lib/client/70-input.js +369 -0
- package/lib/client/80-apply.js +76 -0
- package/lib/client/_head.js +17 -0
- package/lib/client/_tail.js +3 -0
- package/lib/client.js +2262 -0
- package/lib/domain-words.js +13 -0
- package/lib/en-common.js +54 -0
- package/lib/en-lexicon.js +66 -0
- package/lib/fuzzy.js +485 -0
- package/lib/index.js +438 -0
- package/lib/words.js +88 -0
- package/package.json +63 -4
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
// ── 模块:键位层 ────────────────────────────────
|
|
2
|
+
// onKeyDown(配对、跳过闭符、空对删除、方向键选候选)与两个删除动作。
|
|
3
|
+
// 本文件是 lib/client.js 的片段,由 tools/build-client.mjs 按序拼回;不要直接改生成物 lib/client.js。
|
|
4
|
+
|
|
5
|
+
function onKeyDown(ev) {
|
|
6
|
+
var ed = composerOf(ev.target)
|
|
7
|
+
if (!ed) { if (popup.open) closePopup(); return }
|
|
8
|
+
lastComposer = ed
|
|
9
|
+
if (composing) return // IME 合成期间不接管任何键(拼音的字母要原样交给输入法)
|
|
10
|
+
/* 读状态之前,先把**上一键挂起的光标复位**就地落地(幂等:文本不匹配就什么都不做)。
|
|
11
|
+
* 不这么做,下一键读到的就是旧光标位置 —— 空框打 `"` 得 `""` 之后手快再打 `"`,
|
|
12
|
+
* 因为光标还没挪到两符中间,第二下会当成字面插入 → `"""`(一次反馈正是这一拍)。 */
|
|
13
|
+
flushCaretFix()
|
|
14
|
+
if (window.__dceSyntheticBackspace && ev.key === 'Backspace') return
|
|
15
|
+
caretEpoch++ // 新按键作废上一键挂起的光标复位(否则旧回调会把光标拨回去)
|
|
16
|
+
var k = { key: ev.key, code: ev.code, ctrl: ev.ctrlKey || ev.metaKey, shift: ev.shiftKey,
|
|
17
|
+
__popupOpen: popup.open, __collapsedFalse: !!(ev.target && ev.target === ed && selectedText(ed)),
|
|
18
|
+
__nextIsCloser: caretChar(ed, +1) === ev.key }
|
|
19
|
+
dbg('keydown', { ed: ed, key: ev.key, before: textBeforeCaret(ed), composing: composing })
|
|
20
|
+
// 记下这一键按下时的选区:IME/全角那条路 keydown 拿不到字符,靠 input 事件回头补框选
|
|
21
|
+
lastSelText = selectedText(ed)
|
|
22
|
+
// 回车=发送:先把弹层与灰字收干净,别让它们挂在被清空的输入框上。
|
|
23
|
+
// (一次反馈——发送就是这条的收尾点)
|
|
24
|
+
if (ev.key === 'Enter' && !ev.shiftKey && !ev.altKey && !ev.ctrlKey && !ev.metaKey) {
|
|
25
|
+
/* 无条件 closePopup:即使浮层还没画出来(候选响应还在路上)也要把代数 +1,
|
|
26
|
+
否则回车之后到达的响应照样会把它画出来 —— 2026-10-21 真机 3081 把 /words 延迟 900ms 后复现到的正是这一格。 */
|
|
27
|
+
closePopup()
|
|
28
|
+
return
|
|
29
|
+
}
|
|
30
|
+
// 与 dsh-composer-history 共存:它用 Ctrl+R 开反向搜索。插件的候选层开着时先把层收掉,
|
|
31
|
+
// 避免两个面板叠在一起(插件**不拦**这个键,交给它处理)——纸面对照后补。
|
|
32
|
+
if (popup.open && (ev.ctrlKey || ev.metaKey) && (ev.key === 'r' || ev.key === 'R')) closePopup()
|
|
33
|
+
// 片段占位符优先于补全接受:接受片段后 Tab 依次跳到下一个占位符(照 VS Code 的 snippet 行为)。
|
|
34
|
+
// 非 Tab 的按键清空待跳表 —— 用户一动别的,就不该再跳。
|
|
35
|
+
if (snippetStops && snippetStops.length) {
|
|
36
|
+
if (ev.key === 'Tab' && !ev.shiftKey) {
|
|
37
|
+
ev.preventDefault()
|
|
38
|
+
var nowAt = caretPos(ed)
|
|
39
|
+
var nxt = -1
|
|
40
|
+
for (var si = 0; si < snippetStops.length; si++) {
|
|
41
|
+
if (snippetStops[si] > nowAt) { nxt = snippetStops[si]; break }
|
|
42
|
+
}
|
|
43
|
+
if (nxt >= 0) placeCaretVerified(ed, nxt, textOf(ed))
|
|
44
|
+
else snippetStops = null
|
|
45
|
+
return
|
|
46
|
+
}
|
|
47
|
+
snippetStops = null
|
|
48
|
+
}
|
|
49
|
+
// 左右方向键 / Home / End 是「挪光标改字」,不是要补全:先把弹层收掉,再把键交回编辑器。
|
|
50
|
+
// 只收这一次 —— 继续打字时 onInput 会重新拉候选,所以改完接着打仍然会提示。
|
|
51
|
+
if (popup.open && (ev.key === 'ArrowLeft' || ev.key === 'ArrowRight' || ev.key === 'Home' || ev.key === 'End')) {
|
|
52
|
+
closePopup()
|
|
53
|
+
}
|
|
54
|
+
// 弹层优先:上下选必须先于 keyAction —— keyAction 对箭头返回空,放在它后面就永远走不到
|
|
55
|
+
// (的一次反馈)。
|
|
56
|
+
if (popup.open && (ev.key === 'ArrowDown' || ev.key === 'ArrowUp')) {
|
|
57
|
+
ev.preventDefault()
|
|
58
|
+
popup.index = (popup.index + (ev.key === 'ArrowDown' ? 1 : -1) + popup.items.length) % popup.items.length
|
|
59
|
+
// 必须 force:renderPopup 的签名只含 items 与语言水印、不含 index,
|
|
60
|
+
// 非 force 时同一个 sig 会早退 →「按了键但高亮不动」(就是这个)。
|
|
61
|
+
renderPopup(ed, true)
|
|
62
|
+
showGhost(ed) // 行内预览跟着选中项走(VS Code 的 suggest.preview 口径)
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
var act = keyAction(k)
|
|
66
|
+
if (!act) return
|
|
67
|
+
if (act === 'dismiss') { if (popup.open) { ev.preventDefault(); closePopup() } return }
|
|
68
|
+
if (act === 'accept' && popup.open) { ev.preventDefault(); acceptCurrent(); return }
|
|
69
|
+
if (act === 'complete') {
|
|
70
|
+
ev.preventDefault()
|
|
71
|
+
lastToken = tokenBefore(textBeforeCaret(ed))
|
|
72
|
+
fetchCandidates(lastToken, ed)
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
// (一次反馈):原来这里排除了 shift,
|
|
76
|
+
// 于是 `{` `"` 这类要按 Shift 才打出来的开符、以及中文输入法 Shift+9 的「(」全都不配对。
|
|
77
|
+
// 配对动作只对开符触发,Shift 不影响判断,放开它。
|
|
78
|
+
if (act === 'pairopen' && ev.repeat) {
|
|
79
|
+
// 按住符号键的自动重复:只插一个字符,不再成对(否则越按越多)
|
|
80
|
+
ev.preventDefault()
|
|
81
|
+
insertText(ed, ev.key)
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
if (act === 'pairopen' && !ev.altKey && !ev.ctrlKey && !ev.metaKey) {
|
|
85
|
+
// 反选优先:上一次框选范围还在、这次又是同一个引号 → 取消那次框选
|
|
86
|
+
if (tryUnwrap(ed, ev.key)) { ev.preventDefault(); return }
|
|
87
|
+
var before = textBeforeCaret(ed)
|
|
88
|
+
var selTxt = selectedText(ed)
|
|
89
|
+
// 决策走 decideType:全角换算在这一层完成,下面各分支只按 open/close 落地
|
|
90
|
+
var d0 = decideType(ev.key, { before: before, after: caretChar(ed, +1), selection: selTxt, fwHalf: fwHalf() })
|
|
91
|
+
var open = d0.open, close = d0.close
|
|
92
|
+
fwKeyHandled = !!FW_TO_ASCII[ev.key]
|
|
93
|
+
// 明明有选区却读不出文本(选区落在 chip / mention 这类非文本节点上):
|
|
94
|
+
// 这时候任何插入都会把选区替掉,等于用引号吃掉选中内容 —— 宁可这一下什么都不做。
|
|
95
|
+
if (!selTxt && k.__collapsedFalse) { ev.preventDefault(); return }
|
|
96
|
+
// warn 模式:`"你好"` 这种已经框好的,再打同一个引号什么都不做(与 auto 的旧契约一致)
|
|
97
|
+
if (quoteMode() !== 'auto' && isQuoteChar(open) && alreadyClosedBefore(before, open, close)) {
|
|
98
|
+
ev.preventDefault(); return
|
|
99
|
+
}
|
|
100
|
+
// 空对里再打同一个引号 → 跳过(实测:快速连打时上一键的光标复位还没落地,
|
|
101
|
+
// 光标可能停在对子后面,于是又补一对变成三个)。按**文本形状**判,不看光标:
|
|
102
|
+
// 整段就是一个空对,或光标正好夹在空对中间,都算「已经在空对里」。
|
|
103
|
+
if (close === open && isQuoteChar(open) && quoteMode() === 'auto') {
|
|
104
|
+
// 仅 auto 模式:连打竞态下光标可能停在对子后面,此时跳过而不是再补一对。
|
|
105
|
+
// warn 模式不自动补对,连按必须逐个字面插入()。
|
|
106
|
+
var tv = textOf(ed), pv = caretPos(ed)
|
|
107
|
+
var onlyPair = tv === open + close
|
|
108
|
+
var straddle = pv > 0 && pv < tv.length && tv.charAt(pv - 1) === open && tv.charAt(pv) === close
|
|
109
|
+
if (onlyPair || straddle) {
|
|
110
|
+
ev.preventDefault()
|
|
111
|
+
placeCaretVerified(ed, tv.length, tv) // 光标移到闭符之后
|
|
112
|
+
return
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
ev.preventDefault()
|
|
116
|
+
if (selTxt) {
|
|
117
|
+
// 拦住 Lexical:插件的监听是 capture 阶段,stopPropagation 之后它收不到这次按键,
|
|
118
|
+
// 就不会"按自己的模型再插一遍"——实测 实测:不拦时 418ms 贴入 `“abc”`、
|
|
119
|
+
// 423ms 它又插一个 `“`,于是撤销撤掉的是那个多余引号,而不是把原文还回来。
|
|
120
|
+
try { ev.stopImmediatePropagation && ev.stopImmediatePropagation() } catch (e0) {}
|
|
121
|
+
try { ev.stopPropagation() } catch (e0b) {}
|
|
122
|
+
// 已有一个没闭合的开符在前(`"abc` 里选中 abc 再打 `"`):这是「补闭合」而不是「再框一层」,
|
|
123
|
+
// 否则会变成 `""abc"`。对称引号才这么做;非对称的(“”)按原来的框选走。
|
|
124
|
+
if (close === open && isUnbalancedBefore(before, open, close)) {
|
|
125
|
+
var selEnd = caretOffset(ed) + selTxt.length
|
|
126
|
+
if (isText(ed)) { ed.selectionStart = ed.selectionEnd = ed.selectionEnd } else setSelectionRange(ed, selEnd, selEnd)
|
|
127
|
+
insertText(ed, open)
|
|
128
|
+
} else wrapSelection(ed, open, selTxt, close)
|
|
129
|
+
} // 环绕选区(2026-10-16 修:选区还原到内层)
|
|
130
|
+
else if (caretChar(ed, +1) === close) {
|
|
131
|
+
// 右边已经是这一对:跳过去,不要套一对(`“”` 中间再打 `“` 会变成一对套一对)
|
|
132
|
+
var skipTo = caretPos(ed)
|
|
133
|
+
if (skipTo >= 0) placeCaretVerified(ed, skipTo + 1, textOf(ed)) // 不插字,文本不变
|
|
134
|
+
// 光标停在一个**已闭合**的闭符之后时不再吞掉这一键(旧实现 preventDefault 了什么都不做):
|
|
135
|
+
// 反馈是「其他引号都闭合的情况下,新打的引号没有成对」。现在交给 shouldPair 决定,
|
|
136
|
+
// 默认 auto 下就是再开一对;要「不堆一个」请切到标红模式或开严格配对。
|
|
137
|
+
} else if (before.slice(-1) === open
|
|
138
|
+
&& isUnbalancedBefore(before, open, close)
|
|
139
|
+
&& textOf(ed).length > before.length) {
|
|
140
|
+
// 光标紧贴一个未闭合的开符、后面还有正文(`"|你好`)。
|
|
141
|
+
// **旧实现把闭符 setCaret 到全文末尾** —— 这就是
|
|
142
|
+
// 「后引号出现在句子后面、不在我要的位置」。改成一律在光标处字面插入:
|
|
143
|
+
// 用户在哪打就在哪落,闭合与否交给 lintQuotes 标红提示。
|
|
144
|
+
var atHere = caretPos(ed)
|
|
145
|
+
insertText(ed, open)
|
|
146
|
+
if (atHere >= 0) { try { setSelectionRange(ed, atHere + 1, atHere + 1) } catch (e) {} }
|
|
147
|
+
} else if (close === open && before.slice(-1) === open && quoteCount(before) % 2 === 1) {
|
|
148
|
+
// 对称引号(" ' `)前面是**还没闭合**的开符:只插一个(这是补闭合,不是再开一层)。
|
|
149
|
+
// 前面那对已经闭合时走下面 shouldPair,开一对新的。
|
|
150
|
+
// VS Code 的引号规则;不补这条时连按引号会 2→4→6 成串(的一次反馈)。
|
|
151
|
+
insertText(ed, open)
|
|
152
|
+
} else if (isUnbalancedBefore(before, open, close)) {
|
|
153
|
+
// 更前面还有一个没闭合的开符(`"你好` 末尾再打 `"`、`“文字` 末尾再打 `“`):
|
|
154
|
+
// 这一下是在闭合它,不是再开一层 —— 只插一个字符。
|
|
155
|
+
insertText(ed, open)
|
|
156
|
+
} else if (shouldPair(before, caretChar(ed, +1), open)) {
|
|
157
|
+
var basePair = textOf(ed), atPair = caretPos(ed)
|
|
158
|
+
insertAtomic(ed, open + close) // 原子插入:避免 Lexical 异步重写选区导致的竞态
|
|
159
|
+
// 先同步落一次光标:异步观察者赶不上下一次按键(实测快速连打会变 4 个引号)
|
|
160
|
+
if (atPair >= 0) { try { setSelectionRange(ed, atPair + 1, atPair + 1) } catch (e) {} }
|
|
161
|
+
// 开符落在 atPair 这一格,光标要落在它后面一格(= 两符之间)。
|
|
162
|
+
// 等文本真的落地才写(见 placeCaretVerified),expect 用来认出「有没有人插手」
|
|
163
|
+
placeCaretVerified(ed, atPair + 1,
|
|
164
|
+
atPair < 0 ? undefined : basePair.slice(0, atPair) + open + close + basePair.slice(atPair),
|
|
165
|
+
atPair < 0 ? null : afterPairCheck(ed, open, close, atPair))
|
|
166
|
+
if (atPair >= 0) ensureCaretAt(ed, atPair + 1) // 编辑器若把光标挪到对子后面,这里按实际偏移纠回来
|
|
167
|
+
} else {
|
|
168
|
+
var atLiteral = caretPos(ed)
|
|
169
|
+
insertText(ed, open)
|
|
170
|
+
// 光标复位:字面插入后宿主可能把 caret 留在插字之前,导致下一次按键被
|
|
171
|
+
// 「跳过闭符」判据吃掉(实测连按三下只得两个引号)。
|
|
172
|
+
if (atLiteral >= 0) { try { setSelectionRange(ed, atLiteral + 1, atLiteral + 1) } catch (e) {} }
|
|
173
|
+
}
|
|
174
|
+
return
|
|
175
|
+
}
|
|
176
|
+
if (act === 'pairclose') {
|
|
177
|
+
var expect = CLOSERS[ev.key]
|
|
178
|
+
if (!selectedText(ed) && caretChar(ed, +1) === ev.key && PAIRS[expect] === ev.key) {
|
|
179
|
+
// 后面就是应得的闭合符:跳过而不是再插一个(反引号对称,同判据成立)
|
|
180
|
+
ev.preventDefault()
|
|
181
|
+
var atClose = caretPos(ed)
|
|
182
|
+
if (atClose >= 0) placeCaretVerified(ed, atClose + 1, textOf(ed)) // 不插字,文本不变
|
|
183
|
+
return
|
|
184
|
+
}
|
|
185
|
+
// 引号且不该跳过时:必须字面插入,否则这一下被吞掉(实测连按三下只得两个引号)。
|
|
186
|
+
// 括号不在此列 —— 让浏览器默认插入,别抢。
|
|
187
|
+
if (isQuoteChar(ev.key)) {
|
|
188
|
+
var bh = textBeforeCaret(ed)
|
|
189
|
+
if (alreadyClosedBefore(bh, CLOSERS[ev.key], ev.key)) return // 已框好:不再堆一个
|
|
190
|
+
ev.preventDefault()
|
|
191
|
+
var atLit = caretPos(ed)
|
|
192
|
+
insertText(ed, ev.key)
|
|
193
|
+
if (atLit >= 0) { try { setSelectionRange(ed, atLit + 1, atLit + 1) } catch (e) {} }
|
|
194
|
+
}
|
|
195
|
+
return
|
|
196
|
+
return
|
|
197
|
+
}
|
|
198
|
+
if (act === 'pairbackspace' && !selectedText(ed)) {
|
|
199
|
+
var prev = caretChar(ed, -1), next = caretChar(ed, +1)
|
|
200
|
+
if (PAIRS[prev] === next) { // 光标正好夹在空对里:一次删掉两侧(VSCode 同款)
|
|
201
|
+
ev.preventDefault()
|
|
202
|
+
// 两次删除各派发一次 input;全角/中文标点那半边有「开符后自动补闭符」,
|
|
203
|
+
// 前删后框里只剩一个 `(`,它会立刻补回 `)`,结果就变成只删了一侧
|
|
204
|
+
// (取证:`()`→`)`、`“”`→`”`,半角不受影响)。
|
|
205
|
+
suppressInput += 2
|
|
206
|
+
deleteForward(ed)
|
|
207
|
+
deleteBackward(ed)
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
function deleteForward(ed) {
|
|
212
|
+
try {
|
|
213
|
+
if (isText(ed)) {
|
|
214
|
+
var s = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set
|
|
215
|
+
var i = ed.selectionStart
|
|
216
|
+
s.call(ed, ed.value.slice(0, i) + ed.value.slice(i + 1))
|
|
217
|
+
ed.dispatchEvent(new Event('input', { bubbles: true }))
|
|
218
|
+
return
|
|
219
|
+
}
|
|
220
|
+
var off = caretOffset(ed)
|
|
221
|
+
if (off < 0) return
|
|
222
|
+
setSelectionRange(ed, off, off + 1) // 2026-10-16:从 sel.modify 换过来(Lexical 不认后者)
|
|
223
|
+
document.execCommand('delete')
|
|
224
|
+
} catch (e) {}
|
|
225
|
+
}
|
|
226
|
+
function deleteBackward(ed) {
|
|
227
|
+
try {
|
|
228
|
+
if (isText(ed)) {
|
|
229
|
+
var s = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set
|
|
230
|
+
var i = ed.selectionStart
|
|
231
|
+
if (i <= 0) return
|
|
232
|
+
s.call(ed, ed.value.slice(0, i - 1) + ed.value.slice(i))
|
|
233
|
+
ed.selectionStart = ed.selectionEnd = i - 1
|
|
234
|
+
ed.dispatchEvent(new Event('input', { bubbles: true }))
|
|
235
|
+
return
|
|
236
|
+
}
|
|
237
|
+
var off = caretOffset(ed)
|
|
238
|
+
if (off <= 0) return
|
|
239
|
+
setSelectionRange(ed, off - 1, off) // 2026-10-16:同上
|
|
240
|
+
document.execCommand('delete')
|
|
241
|
+
} catch (e) {}
|
|
242
|
+
}
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
// ── 模块:输入事件层 ────────────────────────────────
|
|
2
|
+
// beforeinput/input 两条路:补闭符、收尾、引号未闭合标红、触发源定义(buildTriggerSources)。
|
|
3
|
+
// 本文件是 lib/client.js 的片段,由 tools/build-client.mjs 按序拼回;不要直接改生成物 lib/client.js。
|
|
4
|
+
|
|
5
|
+
function textBeforeCaret(ed) {
|
|
6
|
+
try {
|
|
7
|
+
if (isText(ed)) return ed.value.slice(0, ed.selectionStart)
|
|
8
|
+
var sel = window.getSelection(); if (!sel.rangeCount) return ''
|
|
9
|
+
var r = sel.getRangeAt(0).cloneRange()
|
|
10
|
+
var root = composerOf(ed) || document.body
|
|
11
|
+
r.selectNodeContents(root); r.setEnd(sel.getRangeAt(0).endContainer, sel.getRangeAt(0).endOffset)
|
|
12
|
+
return r.toString()
|
|
13
|
+
} catch (e) { return '' }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/* 引号未闭合提示:**必须在模块层**声明 —— 之前写在 apply() 里,而 scheduleQuoteLint
|
|
17
|
+
* 在模块层引用它们,运行时抛 ReferenceError 被 try 吞掉,红线永远不出现(修)。 */
|
|
18
|
+
var quoteLintTimer = null
|
|
19
|
+
|
|
20
|
+
/** 「未闭合引号」计数:**撇号不算**——`let's` / `don't` 里的 ' 与 ’ 被词字符夹着,
|
|
21
|
+
* 那是英文所有格,不是引号(从没打引号也会冒出标红)。 */
|
|
22
|
+
function unclosedQuoteCount(text) {
|
|
23
|
+
var s = String(text || ''), n = 0
|
|
24
|
+
for (var i = 0; i < s.length; i++) {
|
|
25
|
+
var c = s.charAt(i)
|
|
26
|
+
if (!isQuoteChar(c)) continue
|
|
27
|
+
if (c === '\u0027' || c === '\u2019') {
|
|
28
|
+
var pv = s.charAt(i - 1), nx = s.charAt(i + 1)
|
|
29
|
+
if (/[A-Za-z0-9]/.test(pv) && /[A-Za-z0-9]/.test(nx)) continue
|
|
30
|
+
}
|
|
31
|
+
n++
|
|
32
|
+
}
|
|
33
|
+
return n
|
|
34
|
+
}
|
|
35
|
+
function lintQuotes(ed) {
|
|
36
|
+
if (!ed) return
|
|
37
|
+
var box = composerOf && composerOf(ed) || ed
|
|
38
|
+
if (!box || !box.setAttribute) return
|
|
39
|
+
var t = ''
|
|
40
|
+
try { t = textOf(ed) || '' } catch (e) { t = '' }
|
|
41
|
+
var bad = unclosedQuoteCount(t) % 2 === 1
|
|
42
|
+
paintQuoteWarn(ed, bad ? unmatchedQuoteIndex(t) : -1) // 只画在那个没闭合的引号底下
|
|
43
|
+
try {
|
|
44
|
+
if (bad) {
|
|
45
|
+
box.setAttribute('data-dce-quote-warn', '1')
|
|
46
|
+
// 不再挂文字提示(反馈:不需要显示「引号/反引号未闭合」),只留底下那条波浪线。
|
|
47
|
+
box.setAttribute('data-dce-quote-hint', '')
|
|
48
|
+
} else {
|
|
49
|
+
// 置 '0' 而不是删除:CSS 只认 =1,且假 DOM/宿主里 removeAttribute 不一定生效
|
|
50
|
+
box.setAttribute('data-dce-quote-warn', '0')
|
|
51
|
+
box.setAttribute('data-dce-quote-hint', '')
|
|
52
|
+
if (box.removeAttribute) box.removeAttribute('title')
|
|
53
|
+
}
|
|
54
|
+
} catch (e) {}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function scheduleQuoteLint(target) {
|
|
58
|
+
if (quoteLintTimer) clearTimeout(quoteLintTimer)
|
|
59
|
+
quoteLintTimer = setTimeout(function () { try { lintQuotes(target) } catch (e) {} }, 120)
|
|
60
|
+
}
|
|
61
|
+
/* 注释行里不提示:行首(允许缩进)是 // # <!-- -- 的那一行,补全没有意义。
|
|
62
|
+
* 引号里的内容照旧提示 —— 那一侧是字符串,正是要补的地方。 */
|
|
63
|
+
var COMMENT_LINE = /^\s*(\/\/|#|<!--|--)/
|
|
64
|
+
function inCommentLine(before) {
|
|
65
|
+
var s = String(before || '')
|
|
66
|
+
var i = s.lastIndexOf('\n')
|
|
67
|
+
return COMMENT_LINE.test(i < 0 ? s : s.slice(i + 1))
|
|
68
|
+
}
|
|
69
|
+
function onInput(ev) {
|
|
70
|
+
try { scheduleQuoteLint(ev && ev.target) } catch (e0) {} // 引号未闭合标红(不阻断输入)
|
|
71
|
+
// 三种触发:'/' 与 '@'(引用/模型)、打标识符两个字母起自动弹(VSCode 手感)
|
|
72
|
+
var ed = composerOf(ev.target)
|
|
73
|
+
if (!ed) return
|
|
74
|
+
if (suppressInput > 0) { suppressInput--; return } // 这是接受候选时自己插的,别再弹
|
|
75
|
+
dbg('input', { ed: ed, data: ev && ev.data, itype: ev && ev.inputType,
|
|
76
|
+
composing: composing || (ev && ev.isComposing), before: textBeforeCaret(ed) })
|
|
77
|
+
if (composing || (ev && ev.isComposing)) { if (popup.open) closePopup(); return } // IME 合成中不补全
|
|
78
|
+
// 粘贴/拖入:先把残留的弹层与灰字收掉,也不为粘贴内容补全(否则灰字会停在旧位置)
|
|
79
|
+
var itype = ev && ev.inputType ? String(ev.inputType) : ''
|
|
80
|
+
if (itype.indexOf('Paste') >= 0 || itype.indexOf('Drop') >= 0) { closePopup(); return }
|
|
81
|
+
lastSelText = ''
|
|
82
|
+
// 全角/中文成对符走这条:IME 合成的字符不一定带对应 keydown,keydown 那条路抓不到。
|
|
83
|
+
var b0 = textBeforeCaret(ed)
|
|
84
|
+
var prev = b0.slice(-1)
|
|
85
|
+
var want = PAIRS[prev]
|
|
86
|
+
// `“文字` 末尾再打 `“` 时,上一行插入的是「闭合」而不是「开启」,
|
|
87
|
+
// 这里若还去补 `”` 就会变成 `“文字“”`(一次反馈)。
|
|
88
|
+
// 全角括号开关:打开时把输入法刚合成的 (/【 换成半角对子(只在写代码时开)。
|
|
89
|
+
// FW_TO_ASCII 在 10-foundation 里(模块级):这里的 var 会遮蔽它,导致 beforeinput 那条路取不到
|
|
90
|
+
var keyJustHandled = fwKeyHandled
|
|
91
|
+
fwKeyHandled = false
|
|
92
|
+
if (fwHalf() && FW_TO_ASCII[prev] && !keyJustHandled) {
|
|
93
|
+
var asciiOpen = FW_TO_ASCII[prev]
|
|
94
|
+
var asciiClose = PAIRS[asciiOpen] || ''
|
|
95
|
+
suppressInput++
|
|
96
|
+
if (replaceBeforeCaret(ed, 1, asciiOpen + asciiClose)) return
|
|
97
|
+
suppressInput--
|
|
98
|
+
}
|
|
99
|
+
if (want && prev.charCodeAt(0) > 127 && caretChar(ed, +1) !== want
|
|
100
|
+
&& !isUnbalancedBefore(b0.slice(0, -1), prev, want)
|
|
101
|
+
&& shouldPair(b0.slice(0, -1), caretChar(ed, +1), prev)) {
|
|
102
|
+
suppressInput++ // 自己插的闭符别再触发一轮
|
|
103
|
+
var baseWant = textOf(ed), atWant = caretPos(ed)
|
|
104
|
+
insertText(ed, want)
|
|
105
|
+
// 闭符插在开符之后,光标回到两符之间
|
|
106
|
+
placeCaretVerified(ed, b0.length,
|
|
107
|
+
atWant < 0 ? undefined : baseWant.slice(0, atWant) + want + baseWant.slice(atWant))
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
var before = textBeforeCaret(ed)
|
|
111
|
+
lastComposer = ed
|
|
112
|
+
// 注释行:整行不提示(引号里的内容仍在引号那条规则之外,照旧提示)
|
|
113
|
+
if (inCommentLine(before)) { if (popup.open) closePopup(); return }
|
|
114
|
+
var m = before.match(/(^|\s)([/@])([A-Za-z0-9_.\\/:,-]*)$/)
|
|
115
|
+
if (m) {
|
|
116
|
+
// '/' 是 DSH 原生命令入口(dsh-commands:command/run、command/done):
|
|
117
|
+
// 插件不插手,免得两份菜单叠在一起、也免得给那条路加延迟(融合而不是抢)。
|
|
118
|
+
if (m[2] === '/') { if (popup.open) closePopup(); return }
|
|
119
|
+
lastToken = m[2] + m[3]; fetchCandidates(lastToken, ed); return
|
|
120
|
+
}
|
|
121
|
+
var w = before.match(/([A-Za-z_][A-Za-z0-9_]{1,}|[\u4e00-\u9fff]{1,})$/)
|
|
122
|
+
if (w) {
|
|
123
|
+
var tok = w[1]
|
|
124
|
+
// 汉字没有空格分词:整句会被当成一个 token,取最后 4 个字当前缀(够长又不至于永不命中)
|
|
125
|
+
if (/^[\u4e00-\u9fff]+$/.test(tok)) {
|
|
126
|
+
// 中文至少要 2 个字才算「完整词义」,单字不提示()
|
|
127
|
+
if (tok.length < 2) { if (popup.open) closePopup(); return }
|
|
128
|
+
if (tok.length > 4) tok = tok.slice(-4)
|
|
129
|
+
}
|
|
130
|
+
lastToken = tok
|
|
131
|
+
fetchCandidates(lastToken, ed)
|
|
132
|
+
return
|
|
133
|
+
}
|
|
134
|
+
if (popup.open) closePopup()
|
|
135
|
+
}
|
|
136
|
+
/* 框选 + 打符号(原话「选中然后 shift 加符号框住」):
|
|
137
|
+
* keydown 那条路对半角好使;**中文输入法/全角没有可用的 keydown**,所以再挂一条 beforeinput。
|
|
138
|
+
* 实测取证(demo 3081 + Playwright)把两条老假设推翻了:
|
|
139
|
+
* ① keydown 里读 DOM **不是**空的 —— 探针实测值 `text='\"你好' caret=3` 都对,影子状态不需要;
|
|
140
|
+
* ② `preventDefault()` **拦不住 Lexical**:它照自己的模型再插一遍,于是「拦 + 自己插」出双份。
|
|
141
|
+
* 现存唯一的真空窗是 `input` 事件:空框里首次插入 Lexical 根本不派发它(探针里只有
|
|
142
|
+
* beforeinput,没有 input),所以「事后靠 onInput 补闭符」在那几场永远不触发。
|
|
143
|
+
* 于是这条路改成:**判定在 beforeinput 做,落地等原生插完再收尾**(不拦默认、不自己插开符)。 */
|
|
144
|
+
var pendingFix = null
|
|
145
|
+
function textOf(ed) { return isText(ed) ? String(ed.value || '') : flatText(ed) }
|
|
146
|
+
/** 删掉绝对偏移上的那一个字符(原生多插时用)。 */
|
|
147
|
+
function deleteAt(ed, off) {
|
|
148
|
+
try {
|
|
149
|
+
if (isText(ed)) {
|
|
150
|
+
var next = ed.value.slice(0, off) + ed.value.slice(off + 1)
|
|
151
|
+
var desc = null
|
|
152
|
+
try {
|
|
153
|
+
desc = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')
|
|
154
|
+
} catch (e0) { desc = null }
|
|
155
|
+
if (desc && desc.set) desc.set.call(ed, next) // 受控组件只认原型 setter
|
|
156
|
+
else ed.value = next // 没有描述符的环境直接赋值
|
|
157
|
+
ed.selectionStart = ed.selectionEnd = off
|
|
158
|
+
ed.dispatchEvent(new Event('input', { bubbles: true }))
|
|
159
|
+
return
|
|
160
|
+
}
|
|
161
|
+
setSelectionRange(ed, off, off + 1)
|
|
162
|
+
document.execCommand('delete')
|
|
163
|
+
} catch (e) {}
|
|
164
|
+
}
|
|
165
|
+
function onBeforeInput(ev) {
|
|
166
|
+
try {
|
|
167
|
+
var ed = composerOf(ev.target)
|
|
168
|
+
if (!ed) return
|
|
169
|
+
flushCaretFix() // 同上:先让上一键挂起的光标复位落地,再读状态
|
|
170
|
+
if (suppressInput > 0) return
|
|
171
|
+
var data = ev && typeof ev.data === 'string' ? ev.data : ''
|
|
172
|
+
dbg('beforeinput', { ed: ed, data: data, itype: ev && ev.inputType, before: textBeforeCaret(ed), composing: composing })
|
|
173
|
+
if (data.length !== 1 || !PAIRS[data]) return
|
|
174
|
+
var open = data, close = PAIRS[open]
|
|
175
|
+
var selTxt = selectedText(ed)
|
|
176
|
+
if (selTxt) {
|
|
177
|
+
ev.preventDefault() // 文本域认这次取消;真 Lexical 不认(2026-10-20 探针:照样把选区替成那一个字符)
|
|
178
|
+
// 再断掉传播:宿主那份监听在编辑器根节点上,不拦它就会自己把选区替成那一个字符,
|
|
179
|
+
// 于是包裹变成「多一个开符」再由 repairWrapDup 删掉 —— 撤回里就多出「三个引号、两个引号」两拍。
|
|
180
|
+
try { ev.stopImmediatePropagation && ev.stopImmediatePropagation() } catch (e1) {}
|
|
181
|
+
try { ev.stopPropagation() } catch (e2) {}
|
|
182
|
+
var bfSel = textBeforeCaret(ed)
|
|
183
|
+
var selEnd = bfSel.length + selTxt.length
|
|
184
|
+
if (close === open && isUnbalancedBefore(bfSel, open, close)) {
|
|
185
|
+
// 前面已有一个没闭合的同一个引号:这一下是补闭合,不是再框一层
|
|
186
|
+
setCaret(ed, selEnd)
|
|
187
|
+
insertText(ed, open)
|
|
188
|
+
return
|
|
189
|
+
}
|
|
190
|
+
if (isText(ed)) { wrapSelection(ed, open, selTxt, close); return }
|
|
191
|
+
// contenteditable:Lexical 会把选区替成那一个开符,框完就多出一个
|
|
192
|
+
// (实测值:选中 abc 打 “ → `““abc”`)。所以框完之后把多出来的那一个删掉。
|
|
193
|
+
pendingFix = { ed: ed, open: open, close: close, off: bfSel.length - selTxt.length, selTxt: selTxt, action: 'surround' }
|
|
194
|
+
wrapSelection(ed, open, selTxt, close)
|
|
195
|
+
soon(flushBeforeFix)
|
|
196
|
+
return
|
|
197
|
+
}
|
|
198
|
+
var before = textBeforeCaret(ed)
|
|
199
|
+
var off = before.length
|
|
200
|
+
var next = caretChar(ed, +1)
|
|
201
|
+
// 决策与 keydown 同一条:decideType 给出 open/close,这里只负责落地
|
|
202
|
+
var keyHandled = fwKeyHandled
|
|
203
|
+
fwKeyHandled = false
|
|
204
|
+
var d = decideType(open, { before: before, after: next, selection: '', fwHalf: fwHalf() && !keyHandled })
|
|
205
|
+
if (d.action === 'insert') return // 让原生插一个,零干预
|
|
206
|
+
pendingFix = { ed: ed, open: d.open, close: d.close, off: off, len: before.length, action: d.action }
|
|
207
|
+
soon(flushBeforeFix)
|
|
208
|
+
} catch (e) {}
|
|
209
|
+
}
|
|
210
|
+
/** 原生那一个字符落地之后收尾。**幂等**:实测上 `input` 有时来有时不来,
|
|
211
|
+
* onInput 那条补闭符的路和这里会撞在一起 —— 谁先到都只补一次(落地后先读有没有闭符)。 */
|
|
212
|
+
function flushBeforeFix() {
|
|
213
|
+
var p = pendingFix
|
|
214
|
+
if (!p) return
|
|
215
|
+
pendingFix = null
|
|
216
|
+
try {
|
|
217
|
+
var ed = p.ed, now = textOf(ed)
|
|
218
|
+
if (p.action === 'surround') {
|
|
219
|
+
var ladder = [0, 16, 48, 110, 220], k = 0
|
|
220
|
+
var tick = function () {
|
|
221
|
+
try {
|
|
222
|
+
var nw = textOf(ed)
|
|
223
|
+
if (nw.slice(p.off, p.off + 2) === p.open + p.open) { suppressInput++; deleteAt(ed, p.off); nw = textOf(ed) }
|
|
224
|
+
var afterClose = p.off + 1 + p.selTxt.length + 1
|
|
225
|
+
if (nw.charAt(afterClose - 1) === p.close) { placeCaretVerified(ed, afterClose, nw); return }
|
|
226
|
+
} catch (e) {}
|
|
227
|
+
if (++k < ladder.length) { try { setTimeout(tick, ladder[k]) } catch (e) {} }
|
|
228
|
+
}
|
|
229
|
+
tick()
|
|
230
|
+
return
|
|
231
|
+
}
|
|
232
|
+
if (p.action === 'convert') {
|
|
233
|
+
var ladderC = [0, 16, 48, 110, 220, 360], kc = 0
|
|
234
|
+
var pairTxt = p.open + p.close
|
|
235
|
+
var tickC = function () {
|
|
236
|
+
try {
|
|
237
|
+
var cur = textOf(ed)
|
|
238
|
+
if (FULL_TO_HALF_OPEN[cur.charAt(p.off)]) {
|
|
239
|
+
backspaceOnce(ed)
|
|
240
|
+
setCaret(ed, p.off)
|
|
241
|
+
suppressInput++
|
|
242
|
+
insertText(ed, pairTxt)
|
|
243
|
+
placeCaretVerified(ed, p.off + 1, cur.slice(0, p.off) + pairTxt + cur.slice(p.off + 1))
|
|
244
|
+
return
|
|
245
|
+
}
|
|
246
|
+
} catch (e) {}
|
|
247
|
+
if (++kc < ladderC.length) { try { setTimeout(tickC, ladderC[kc]) } catch (e) {} }
|
|
248
|
+
}
|
|
249
|
+
tickC()
|
|
250
|
+
return
|
|
251
|
+
}
|
|
252
|
+
if (p.action === 'skip') {
|
|
253
|
+
if (now.length === p.len) { placeCaretVerified(ed, Math.min(p.off + 1, now.length), now); return }
|
|
254
|
+
if (now.charAt(p.off) !== p.open) return
|
|
255
|
+
suppressInput++
|
|
256
|
+
deleteAt(ed, p.off)
|
|
257
|
+
var afterDel = textOf(ed)
|
|
258
|
+
placeCaretVerified(ed, Math.min(p.off + 1, afterDel.length), afterDel)
|
|
259
|
+
return
|
|
260
|
+
}
|
|
261
|
+
if (now.charAt(p.off) !== p.open) return
|
|
262
|
+
if (now.charAt(p.off + 1) === p.close) { placeCaretVerified(ed, p.off + 1, now); return }
|
|
263
|
+
suppressInput++
|
|
264
|
+
setCaret(ed, p.off + 1)
|
|
265
|
+
insertText(ed, p.close)
|
|
266
|
+
try { setSelectionRange(ed, p.off + 1, p.off + 1) } catch (e) {}
|
|
267
|
+
placeCaretVerified(ed, p.off + 1, now.slice(0, p.off + 1) + p.close + now.slice(p.off + 1),
|
|
268
|
+
afterPairCheck(ed, p.open, p.close, p.off))
|
|
269
|
+
ensureCaretAt(ed, p.off + 1)
|
|
270
|
+
} catch (e) {}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function onCompositionStart(ev) {
|
|
274
|
+
if (!composerOf(ev.target)) return
|
|
275
|
+
composing = true
|
|
276
|
+
caretEpoch++ // 输入法接管:作废我们自己挂起的光标复位,别跟它抢
|
|
277
|
+
closePopup() // 连灰字一起收起来(hideGhost 在 closePopup 里)
|
|
278
|
+
}
|
|
279
|
+
function onCompositionEnd(ev) {
|
|
280
|
+
if (!composerOf(ev.target)) return
|
|
281
|
+
composing = false
|
|
282
|
+
// 上屏后按新内容重新判一次(中文词补全走这条路)
|
|
283
|
+
try { onInput({ target: ev.target }) } catch (e) {}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function onBlurOut(ev) {
|
|
287
|
+
var ed = composerOf(ev.relatedTarget)
|
|
288
|
+
if (!ed && popup.open) closePopup()
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/* ── 宿主输入触发源(照被录用插件的样子做)─────────────────────────────
|
|
292
|
+
* dsh-client-ui-commands / -skill / -reference 都是把内容注册进宿主的输入触发菜单,
|
|
293
|
+
* 位置、上下选、插入与撤销都由宿主负责。插件只提供 candidates 与 onPick,
|
|
294
|
+
* 不再自己画弹层、也不再跟宿主抢触发字符。 */
|
|
295
|
+
/* 「严格配对」开关(普通情况不必那么严,需要时可开)。
|
|
296
|
+
* 存在 localStorage,默认 false;由 '/' 菜单里插件那组的第一条切换。 */
|
|
297
|
+
// 借宿主的命令表做两件事:① 让本组与「指令」组同一拍落地(宿主那组要等命令目录 RPC,
|
|
298
|
+
// 原来立即 resolve,菜单先蹦三行再被顶下去);② 错拼时按它的表补一行。
|
|
299
|
+
// 取不到(老宿主 / 没这个服务)就照旧立即返回,功能不缺。
|
|
300
|
+
function hostCommands(ctx, session, req) {
|
|
301
|
+
var cu = null
|
|
302
|
+
try { cu = ctx && typeof ctx.get === 'function' ? ctx.get('commandUi') : null } catch (e) { cu = null }
|
|
303
|
+
if (!cu || typeof cu.candidates !== 'function') return Promise.resolve(null)
|
|
304
|
+
var full = { query: '', position: (req && req.position) || 'leading', signal: req && req.signal }
|
|
305
|
+
try {
|
|
306
|
+
return Promise.resolve(cu.candidates(session, req)).then(function (matched) {
|
|
307
|
+
return Promise.resolve(cu.candidates(session, full)).then(function (all) {
|
|
308
|
+
return { matched: matched || [], all: all || [] }
|
|
309
|
+
})
|
|
310
|
+
}).catch(function () { return null })
|
|
311
|
+
} catch (e) { return Promise.resolve(null) }
|
|
312
|
+
}
|
|
313
|
+
function buildTriggerSources(ctx) {
|
|
314
|
+
var fileSource = {
|
|
315
|
+
trigger: '@',
|
|
316
|
+
name: '便捷输入框', // 分组名:与宿主「文件与文件夹」并列,名字要唯一(重复注册会抛)
|
|
317
|
+
order: 50,
|
|
318
|
+
showGroupTitle: true,
|
|
319
|
+
candidates: function (session, req) {
|
|
320
|
+
var q = String((req && req.query) || '')
|
|
321
|
+
return fetchItems(q).then(function (items) {
|
|
322
|
+
return items.map(function (it) {
|
|
323
|
+
var v = String(it.value || '')
|
|
324
|
+
return {
|
|
325
|
+
name: v,
|
|
326
|
+
label: triggerBasename(v),
|
|
327
|
+
description: String(it.detail || ''),
|
|
328
|
+
hint: it.kind === 'file' ? '文件' : (it.kind === 'model' ? '模型' : ''),
|
|
329
|
+
}
|
|
330
|
+
})
|
|
331
|
+
})
|
|
332
|
+
},
|
|
333
|
+
onPick: function (pick) { return { text: String(pick.candidate.name) } },
|
|
334
|
+
}
|
|
335
|
+
var slashSource = {
|
|
336
|
+
trigger: '/',
|
|
337
|
+
name: '便捷输入框', // 与宿主的「指令」组并列;同 trigger 下 name 重复会抛
|
|
338
|
+
// 宿主按 order 升序排:内置「指令」= 1、「技能」= 2。写 20 会被垫到技能下面
|
|
339
|
+
// (的一次反馈)。开关类要放最上面,一打开菜单就看得到。
|
|
340
|
+
order: 0,
|
|
341
|
+
showGroupTitle: true,
|
|
342
|
+
candidates: function (session, req) {
|
|
343
|
+
var q = String((req && req.query) || '').trim()
|
|
344
|
+
// 三行也按 query 过滤:宿主那组因为 query 不命中而消失时,别只剩本插件这组还赖在菜单里
|
|
345
|
+
var rows = settingsRows().filter(function (r) {
|
|
346
|
+
if (!q) return true
|
|
347
|
+
return looseScore(q, r.name) >= 0 || looseScore(q, r.label || '') >= 0
|
|
348
|
+
})
|
|
349
|
+
return hostCommands(ctx, session, req).then(function (host) {
|
|
350
|
+
var leading = !req || req.position !== 'inline'
|
|
351
|
+
return rows.concat(rescueRows(q, host, leading))
|
|
352
|
+
})
|
|
353
|
+
},
|
|
354
|
+
onPick: function (pick) {
|
|
355
|
+
var cand = (pick && pick.candidate) || {}
|
|
356
|
+
// 错拼兜底行:补成真命令名再交给宿主,插件自己不解命令
|
|
357
|
+
if (cand.rescue) return { text: '/' + String(cand.rescue) + ' ' }
|
|
358
|
+
if (String(pick.candidate.name) === '引号模式') setQuoteMode(quoteMode() === 'auto' ? 'warn' : 'auto')
|
|
359
|
+
if (String(pick.candidate.name) === '重置插件设置') resetSettings()
|
|
360
|
+
if (String(pick.candidate.name) === '全角括号') setFwHalf(!fwHalf())
|
|
361
|
+
if (String(pick.candidate.name) === '严格配对') setPairStrict(!pairStrict())
|
|
362
|
+
var pickName = String(pick.candidate.name)
|
|
363
|
+
if (pickName === '候选类型') cycleKindPreset()
|
|
364
|
+
return { text: '' } // 清掉输入框里的 '/',不插入任何文字
|
|
365
|
+
},
|
|
366
|
+
}
|
|
367
|
+
return [fileSource, slashSource]
|
|
368
|
+
}
|
|
369
|
+
|