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/lib/client.js ADDED
@@ -0,0 +1,2262 @@
1
+ // dsh-composer-ease —— 浏览器半边:键位层 + 补全弹层。
2
+ // 形态:宿主模块图里的一个 bundle(照 dsh-skill-meter / dsh-model-priority 的头尾抄结构),
3
+ // 不 require 任何第三方包(动态包里也只拿得到宿主 react,本件用不到)。
4
+ // 行为参照 VSCode(MIT):自动配对、环绕选区、输入闭合符跳过、删除空对、Ctrl+Space 补全。
5
+ // **未复制 VSCode 源码**,只对齐交互;出处见 README 的「来源」节。
6
+ // 补全两类(加第 2 类):
7
+ // 1) 本文词(VSCode 的 word based suggestions 口径):草稿里出现过的标识符,本地即时算,不等网络;
8
+ // 2) 语言词表:HTML 标签 / JS·Python·Java 关键字 / CSS 属性名,服务端 lib/words.js 供,
9
+ // 数据借自 monaco-editor、codemirror、MDN BCD(均为宽松许可,源地址与 commit 见 words.js 头部与 THIRD-PARTY.md)。
10
+ // 触发:'/' 与 '@',以及**打满两个标识符字符就自动弹**(VSCode 手感);Tab/Enter 接受、Esc 关、上下选。
11
+ // ⚠ 本文件由 tools/build-client.mjs 从 lib/client/*.js 生成,**不要直接改**。
12
+ // 改片段:lib/client/10-foundation.js、20-pairing.js、30-popup.js、40-language.js、45-settings.js、50-caret.js、60-keys.js、70-input.js、80-apply.js
13
+
14
+ window.__ModuleLoader__.load({
15
+ id: 'dsh-composer-ease',
16
+ factory: function (require) {
17
+ var module = { exports: {} }
18
+ var exports = module.exports
19
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
20
+
21
+ // ── 模块:表与文本工具 ────────────────────────────────
22
+ // 成对符号表(PAIRS/CLOSERS/家族/奇偶判据)、shouldPair、以及 contenteditable 的取文/摆光标/插字原语。
23
+ // 本文件是 lib/client.js 的片段,由 tools/build-client.mjs 按序拼回;不要直接改生成物 lib/client.js。
24
+
25
+ var ROUTE_WORDS = '/dsh-composer-ease/words'
26
+ // 加全角/中文成对符:实测「输入( 只出(」——中文输入法下原来完全没有配对。
27
+ // 全角走 input 事件那条补配对(IME 合成的字符不一定有对应的 keydown)。
28
+ var PAIRS = { '(': ')', '[': ']', '{': '}', '`': '`', '"': '"', "'": "'",
29
+ '(': ')', '【': '】', '「': '」', '『': '』', '《': '》', '〈': '〉', '“': '”', '‘': '’' }
30
+ var CLOSERS = { ')': '(', ']': '[', '}': '{', '`': '`', '"': '"', "'": "'",
31
+ ')': '(', '】': '【', '」': '「', '』': '『', '》': '《', '〉': '〈', '”': '“', '’': '‘' }
32
+
33
+ // ── 纯逻辑(selftest 直接喂)───────────────────────────────────────────
34
+ /** **成对家族**:所有成对符号的开符与闭符(半角 + 中文标点)。
35
+ * 三条反馈同一类:①有前引号再打引号出两个 ②中文模式打引号变多个
36
+ * ③括号也一样(`(` 后面再打 `(` 会变 `(()`)——所以守卫要从「引号」扩到整个家族:
37
+ * 光标前一个字符是家族里的任意成员时,只插一个字符、不补对。 */
38
+ var PAIRED_CHAR = /[()[\]{}()【】「」『』《》〈〉“”‘’"'`]$/
39
+
40
+ /** 光标前这段文字里,`open` 是否处在一个**没闭合**的状态。
41
+ * 一次反馈就是这个漏的:
42
+ * 原来只看前一个字符(`PAIRED_CHAR`),当前面是「好」这种正文时看不到更早那个开符,
43
+ * 于是又补一对,得到 `"你好""`。对称引号按奇偶判,非对称按开符多于闭符判。 */
44
+ /* 引号家族(根因):全角 “ 与半角 " 在判定里必须算同一个"引号位",
45
+ * 否则中文输入法下混着打就永远被判成"没有开符",于是一路补对、各处冒出多个引号。
46
+ * 规则只有一条:**引号永远成对,家族内合计字符数为奇 = 还有未闭合的引号**。
47
+ * 括号不进家族:`(` 与 `(` 语义不同,混用不算一对。 */
48
+ var QUOTE_FAMILY = '"\u201c\u201d\u201e\u201f\uff02\u0027\u2018\u2019\u201a\u201b\uff07'
49
+ function isQuoteChar(ch) { return !!ch && QUOTE_FAMILY.indexOf(ch) >= 0 }
50
+ function quoteCount(text) {
51
+ var q = String(text || ''), n = 0
52
+ for (var i = 0; i < q.length; i++) if (QUOTE_FAMILY.indexOf(q[i]) >= 0) n++
53
+ return n
54
+ }
55
+
56
+ /** 没闭合的那个引号在全文里的下标(撇号不算,与 unclosedQuoteCount 同口径)。
57
+ * 标红要画在**这个字符**底下,而不是整行:一行里只有一个引号真的有问题。 */
58
+ function unmatchedQuoteIndex(text) {
59
+ var s = String(text || ''), n = 0, last = -1
60
+ for (var i = 0; i < s.length; i++) {
61
+ var c = s.charAt(i)
62
+ if (!isQuoteChar(c)) continue
63
+ if (c === '\u0027' || c === '\u2019') {
64
+ var pv = s.charAt(i - 1), nx = s.charAt(i + 1)
65
+ if (/[A-Za-z0-9]/.test(pv) && /[A-Za-z0-9]/.test(nx)) continue
66
+ }
67
+ n++
68
+ last = i
69
+ }
70
+ return n % 2 === 1 ? last : -1
71
+ }
72
+
73
+ function isUnbalancedBefore(text, open, close) {
74
+ var s = String(text || '')
75
+ // 引号按家族奇偶判:`“你好` 与 `"你好` 同为未闭合,混着打也算一对
76
+ if (isQuoteChar(open) || isQuoteChar(close)) return quoteCount(s) % 2 === 1
77
+ var o = 0, c = 0
78
+ for (var i = 0; i < s.length; i++) {
79
+ if (s[i] === open) o++
80
+ else if (close !== open && s[i] === close) c++
81
+ }
82
+ return close === open ? (o % 2 === 1) : (o > c)
83
+ }
84
+ /** 光标前那一段的最后是不是「一个**非空**成对符号的闭符」——即这块内容已经被框好了。
85
+ * 用来挡「框完再打一次闭符,于是多堆一个」:`"你好"` 再打 `"` 不该变成 `"你好""`。
86
+ * **空对(`""`、`()`)不算**:连打引号/括号是「写 ``` 围栏」那类正常用法,见行为矩阵里的连按用例。 */
87
+ function alreadyClosedBefore(before, open, close) {
88
+ var s = String(before || '')
89
+ if (s.length < 3) return false // 至少要「开符 + 一个字符 + 闭符」
90
+ // 只认「同字符」的已闭合(`"abc"` 后再打 " 才跳过)。
91
+ // 混宽一律不吞 —— 那是的一次反馈。
92
+ if (s.charAt(s.length - 1) !== close) return false
93
+ if (s.charAt(s.length - 2) === open) return false // 空对:放行(连按引号仍要能打出来)
94
+ return isUnbalancedBefore(s.slice(0, -1), open, close)
95
+ }
96
+ /** 该不该补闭符:上一个字符是成对家族成员→不配;右边紧跟字母/数字/汉字→不配;其余补。 */
97
+ /** 唯一决策入口:纯函数,返回 { action: wrap|skip|convert|pair|insert, open, close }。 */
98
+ function decideType(ch, ctx) {
99
+ var c = ctx || {}
100
+ var before = String(c.before || '')
101
+ var after = String(c.after || '')
102
+ var open = ch, close = PAIRS[ch] || ''
103
+ if (c.selection) return { action: 'wrap', open: open, close: close }
104
+ if (c.fwHalf && FW_TO_ASCII[ch]) { open = FW_TO_ASCII[ch]; close = PAIRS[open] || ''; return { action: 'convert', open: open, close: close } }
105
+ if (after === close && close) return { action: 'skip', open: open, close: close }
106
+ if (close && alreadyClosedBefore(before, open, close)) return { action: 'insert', open: open, close: close }
107
+ if (close === open && before.slice(-1) === open) return { action: 'insert', open: open, close: close }
108
+ if (close && isUnbalancedBefore(before, open, close)) return { action: 'insert', open: open, close: close }
109
+ if (shouldPair(before, after, ch)) return { action: 'pair', open: open, close: close }
110
+ return { action: 'insert', open: open, close: close }
111
+ }
112
+
113
+ // VS Code 打字拦截层(模式 + before 集合 + 编辑列表),常量取自其产物
114
+ var AC_MODES = ['always', 'languageDefined', 'beforeWhitespace', 'never']
115
+ var AC_BEFORE_QUOTES = ';:.,=}])> \t'
116
+ var AC_BEFORE_BRACKETS = '\'"`;:.,=}])> \t'
117
+ var AC_BEFORE_WS = ' \t'
118
+
119
+ /** 设置 → 模式(认不得的值按 languageDefined 处理,与 VS Code 的默认值一致)。 */
120
+ function autoCloseMode(kind) {
121
+ var v = ''
122
+ try { v = String(window.localStorage.getItem('dsh-composer-ease:autoClose' + (kind === 'quote' ? 'Quotes' : 'Brackets')) || '') } catch (e) {}
123
+ return AC_MODES.indexOf(v) >= 0 ? v : 'languageDefined'
124
+ }
125
+ function setAutoCloseMode(kind, mode) {
126
+ try { window.localStorage.setItem('dsh-composer-ease:autoClose' + (kind === 'quote' ? 'Quotes' : 'Brackets'), AC_MODES.indexOf(mode) >= 0 ? mode : 'languageDefined') } catch (e) {}
127
+ }
128
+
129
+ /** VS Code 的 shouldAutoCloseBefore:只有前一个字符落在集合里才补对。kind 决定用哪张集合。 */
130
+ function shouldAutoCloseBefore(prev, mode, kind) {
131
+ var m = AC_MODES.indexOf(mode) >= 0 ? mode : 'languageDefined'
132
+ if (m === 'always') return true
133
+ if (m === 'never') return false
134
+ if (m === 'beforeWhitespace') return AC_BEFORE_WS.indexOf(prev) >= 0
135
+ var set = kind === 'quote' ? AC_BEFORE_QUOTES : AC_BEFORE_BRACKETS
136
+ // 空上下文、中文、字母数字后允许成对
137
+ if (!prev) return true
138
+ if (/[\u4e00-\u9fff]/.test(prev)) return true
139
+ if (/[A-Za-z0-9_]/.test(prev)) return true
140
+ return set.indexOf(prev) >= 0
141
+ }
142
+
143
+ /** 打一个字符要做的编辑(VS Code 的 typeWithInterceptors 子集):
144
+ * 返回 [{ range: [from, to], text }],按 from 升序;空数组=不干预,交原生插入。 */
145
+ function buildTypeEdits(ch, ctx) {
146
+ var c = ctx || {}
147
+ var before = String(c.before || ''), after = String(c.after || '')
148
+ var sel = c.selection || ''
149
+ var off = before.length
150
+ var close = PAIRS[ch] || ''
151
+ var kind = isQuoteChar(ch) ? 'quote' : 'bracket'
152
+ var mode = autoCloseMode(kind)
153
+ var edits = []
154
+ // 有选区:框住(surround)
155
+ if (sel) {
156
+ return [{ range: [off, off + sel.length], text: ch + sel + close, caret: off + 1, select: [off + 1, off + 1 + sel.length] }]
157
+ }
158
+ // 全角括号开关:换算成半角对子
159
+ if (c.fwHalf && FW_TO_ASCII[ch]) { ch = FW_TO_ASCII[ch]; close = PAIRS[ch] || '' }
160
+ // 右边已经是同一个闭符:跳过去(overtype)
161
+ if (close && after === close) return [{ range: [off + 1, off + 1], text: '', caret: off + 1 }]
162
+ if (!close) return edits
163
+ // 已闭合 / 未闭合:只插一个
164
+ if (alreadyClosedBefore(before, ch, close)) return edits
165
+ if (close === ch && before.slice(-1) === ch) return edits
166
+ if (isUnbalancedBefore(before, ch, close)) return edits
167
+ if (!shouldAutoCloseBefore(before.slice(-1), mode, kind)) return edits
168
+ if (/[A-Za-z0-9_\u4e00-\u9fff]/.test(String(after || ''))) return edits // 右边紧跟文字时不补(VS Code 的 beforeWhitespace 精神)
169
+ return [{ range: [off, off], text: ch + close, caret: off + 1 }]
170
+ }
171
+
172
+ function shouldPair(before, after, ch) {
173
+ // 引号(恢复「打一个引号得一对」):
174
+ // 严格配对开 → 老行为,一律补对;
175
+ // 严格配对关(默认)→ 也补对,但按 VS Code `editor.autoClosingQuotes` 的
176
+ // `beforeWhitespace` 口径收窄:**右边紧跟文字时不补**(保护「在一段已有文字前打前引号」,
177
+ // 实测发现的多一个引号就是这条),左边已是成对符号时也不补。
178
+ // 历史:曾一律只插一个,原因是配对后光标靠 DOM Range 复位,
179
+ // 快速连打会多冒一个引号;现在走的是括号那条 `insertAtomic + placeCaretVerified`
180
+ // 的复核链(文本护栏 + 跨帧校验),所以放回来。
181
+ if (isQuoteChar(ch)) {
182
+ // warn 模式(默认):不自动补闭引号 —— 闭符落到句子末尾、以及"打完一个引号多一个"的
183
+ // 两类投诉都源于自动补对;这里改为只插当前字符,由 lintQuotes 标红提示。
184
+ if (quoteMode() !== 'auto') return false
185
+ if (pairStrict()) return true
186
+ if (/[A-Za-z0-9_\u4e00-\u9fff]/.test(String(after || ''))) return false
187
+ // 引号是自对称的,前一个引号到底是开符还是闭符只能看**奇偶**:奇数 = 还悬着一个开符,
188
+ // 这一下不该再补对;偶数 = 前面那对已经闭合,新打的引号应当开一对新的(反馈:其他引号都闭合
189
+ // 的情况下,新打引号不出对)。
190
+ var lastCh = String(before || '').slice(-1)
191
+ if (isQuoteChar(lastCh) && quoteCount(String(before || '')) % 2 === 1) return false
192
+ if (!isQuoteChar(lastCh) && PAIRED_CHAR.test(lastCh)) return false
193
+ return true
194
+ }
195
+ if (/[A-Za-z0-9_\u4e00-\u9fff]/.test(String(after || ''))) return false
196
+ var last = before.slice(-1)
197
+ if (PAIRED_CHAR.test(last)) return false
198
+ // 与 VS Code 的 `editor.autoClosingBrackets: languageDefined` 对齐:词/数字后面照样补对
199
+ // (`abc` 后打 `(` 得 `abc()`)。旧规则在标识符后面不补,用户看到的就是「括号不配对」。
200
+ return true
201
+ }
202
+ /** 取 caret 前的当前 token(补全用):文件/标识符字符集,遇空白或换行截断。 */
203
+ /** 展开片段占位符:`${n:默认值}` → 默认值(或空),同时算出每个落点的**偏移**。
204
+ * 返回 { text, stops };stops 按占位符编号升序(与出现顺序无关),供 Tab 依次跳。 */
205
+ function expandSnippet(v) {
206
+ var src = String(v || '')
207
+ var re = /\$\{(\d+)(?::([^}]*))?\}/g
208
+ var out = '', stops = [], m, last = 0
209
+ while ((m = re.exec(src))) {
210
+ out += src.slice(last, m.index)
211
+ out += m[2] || ''
212
+ stops.push({ n: Number(m[1]), at: out.length })
213
+ last = re.lastIndex
214
+ }
215
+ out += src.slice(last)
216
+ stops.sort(function (a, b) { return a.n - b.n })
217
+ return { text: out, stops: stops.map(function (s) { return s.at }) }
218
+ }
219
+
220
+ function tokenBefore(text) {
221
+ var m = String(text || '').match(/([A-Za-z0-9_./@:,-]*)$/)
222
+ return m ? m[1] : ''
223
+ }
224
+ /** 键位决策表:返回动作名,selftest 与真实监听共用判据。 */
225
+ function keyAction(k) {
226
+ if (k.ctrl && k.code === 'Space') return 'complete'
227
+ if (k.key === 'Escape') return 'dismiss'
228
+ // (实测发现的):聊天框里**回车必须发送**,不能被补全弹层吃掉。
229
+ // 原来 Enter 也算接受,弹层开着时连按回车就把候选一遍遍插进正文(打出
230
+ // "i临时目录/attrib-index.dif临时目录/attrib-index.dif…" 那种滚雪球)。接受只留 Tab。
231
+ if (k.__popupOpen && k.key === 'Tab') return 'accept'
232
+ if (k.key === 'Backspace' && !k.__collapsedFalse) return 'pairbackspace'
233
+ // 对称引号(" ' `)右边已经是同一个字符:走「跳过」而不是再补一对(否则就是多出来的那一对)
234
+ if (k.__nextIsCloser && Object.prototype.hasOwnProperty.call(CLOSERS, k.key)) return 'pairclose'
235
+ if (Object.prototype.hasOwnProperty.call(PAIRS, k.key)) return 'pairopen'
236
+ if (Object.prototype.hasOwnProperty.call(CLOSERS, k.key)) return 'pairclose'
237
+ return null
238
+ }
239
+
240
+ // ── DOM 层 ──────────────────────────────────────────────────────────────
241
+ function composerOf(el) {
242
+ if (!el || el === document.body) return null
243
+ var c = el.closest ? el.closest('[data-composer-input], textarea, [contenteditable="true"], [contenteditable=""]') : null
244
+ return c
245
+ }
246
+ function isText(ed) {
247
+ return ed && (ed.tagName === 'TEXTAREA' || ed.tagName === 'INPUT')
248
+ }
249
+ /* 插件的 keydown 监听在**捕获阶段**,跑在 Lexical 自己的键位处理之前;插入后立刻改光标/选区
250
+ * 会被 Lexical 随后的渲染覆盖(实测扫出来的)。contenteditable 这条路一律往后推一拍再改选区。 */
251
+ function soon(fn) { try { setTimeout(fn, 0) } catch (e) { fn() } }
252
+
253
+ // 全角 → 半角映射(模块级:beforeinput 与 input 两条路都要用,放在某个处理器里会 ReferenceError)
254
+ var FW_TO_ASCII = { '(': '(', '【': '[' }
255
+ var FULL_TO_HALF_OPEN = { '(': 1, '【': 1 } // 需要换算的全角开符 // 只换括号:引号族保持输入法给的字符
256
+ // keydown 已经自己换过的那一下,beforeinput 不要再换一遍(否则 ( 变成 (() )
257
+ var fwKeyHandled = false
258
+
259
+ /** 删光标前一个字符:Lexical 只认按键路径的删除。 */
260
+ function backspaceOnce(ed) {
261
+ try {
262
+ window.__dceSyntheticBackspace = true
263
+ var ev = new KeyboardEvent('keydown', { key: 'Backspace', code: 'Backspace', keyCode: 8, which: 8, bubbles: true, cancelable: true })
264
+ ed.dispatchEvent(ev)
265
+ window.__dceSyntheticBackspace = false
266
+ return true
267
+ } catch (e) { window.__dceSyntheticBackspace = false; return false }
268
+ }
269
+
270
+ function insertText(ed, text) {
271
+ if (isText(ed)) {
272
+ var s = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set
273
+ var start = ed.selectionStart, end = ed.selectionEnd
274
+ s.call(ed, ed.value.slice(0, start) + text + ed.value.slice(end))
275
+ ed.selectionStart = ed.selectionEnd = start + text.length
276
+ ed.dispatchEvent(new Event('input', { bubbles: true }))
277
+ } else {
278
+ ed.focus()
279
+ document.execCommand('insertText', false, text)
280
+ }
281
+ }
282
+ /** 插文本的**通用入口**:多行走合成 paste(实测:片段是多行的,
283
+ * `execCommand('insertText')` 在真 Lexical 上会被吃掉 —— 连点选片段都插不进去)。
284
+ * 宿主接下 paste(返回 false = preventDefault 过)就不再兜底;否则退回 execCommand。 */
285
+ function insertRich(ed, text) {
286
+ var s = String(text == null ? '' : text)
287
+ if (isText(ed) || s.indexOf('\n') < 0) return insertText(ed, s)
288
+ try {
289
+ var dt = new DataTransfer()
290
+ dt.setData('text/plain', s)
291
+ var evP = new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })
292
+ if (ed.dispatchEvent(evP) === false) return
293
+ } catch (e) {}
294
+ insertText(ed, s)
295
+ }
296
+
297
+ function selectedText(ed) {
298
+ if (isText(ed)) return ed.value.slice(ed.selectionStart, ed.selectionEnd)
299
+ var sel = window.getSelection()
300
+ return sel && !sel.isCollapsed ? sel.toString() : ''
301
+ }
302
+
303
+ /* 实测扫出来的坑:Lexical 不认 `sel.modify(...)` —— 在真 composer 上
304
+ * 配对后的光标、跳闭合、删空对、环绕选区还原、Tab 替换 token 全都没生效(值对了、位置全错)。
305
+ * 改成把 composer 文本摊平成一个字符偏移,用显式 Range 定位/选区。文本节点之外的东西
306
+ * (br、mention 之类)按 0 长处理:真 composer 里就是纯文本,够用。 */
307
+ function flatText(ed) {
308
+ try {
309
+ var w = document.createTreeWalker(ed, 4 /* SHOW_TEXT */, null)
310
+ var out = '', node
311
+ while ((node = w.nextNode())) out += node.nodeValue || ''
312
+ return out
313
+ } catch (e) { return '' }
314
+ }
315
+ /** 数一个文档片段里文本节点的字符数(与 flatText 同口径:只数文本节点,不含块边界)。 */
316
+ function textLenOf(frag) {
317
+ try {
318
+ var w = document.createTreeWalker(frag, 4 /* SHOW_TEXT */, null)
319
+ var n = 0, node
320
+ while ((node = w.nextNode())) n += (node.nodeValue || '').length
321
+ return n
322
+ } catch (e) { return 0 }
323
+ }
324
+ function caretOffset(ed) {
325
+ try {
326
+ var sel = window.getSelection()
327
+ if (!sel || !sel.rangeCount) return -1
328
+ var r = sel.getRangeAt(0)
329
+ var pre = r.cloneRange()
330
+ pre.selectNodeContents(ed)
331
+ pre.setEnd(r.endContainer, r.endOffset)
332
+ return pre.toString().length
333
+ } catch (e) { return -1 }
334
+ }
335
+ /** 摆光标:文本域直接写 selectionStart,contenteditable 才需要 Range(setSelectionRange 只认 Selection)。 */
336
+ function setCaret(ed, off) {
337
+ if (off < 0) return
338
+ if (isText(ed)) { try { ed.selectionStart = ed.selectionEnd = off } catch (e) {} ; return }
339
+ setSelectionRange(ed, off, off)
340
+ }
341
+ /** 当前光标偏移,文本域与 contenteditable 同一个口径(caretOffset 只认 Selection)。 */
342
+ function caretPos(ed) {
343
+ if (isText(ed)) { try { return ed.selectionStart } catch (e) { return -1 } }
344
+ return caretOffset(ed)
345
+ }
346
+ /** 把选区设成 composer 内的 [start, end) 字符区间;失败返回 false。 */
347
+ /** 把光标前 count 个字符替换成 text(text 两字符时,光标落在两字符之间)。
348
+ * 输入法把 ( 合成 ( 之后要换成 (),text-input 与 contenteditable 两条路都得能走。 */
349
+ function replaceBeforeCaret(ed, count, text) {
350
+ if (isText(ed)) {
351
+ var pos = ed.selectionStart
352
+ if (pos < count) return false
353
+ var set = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set
354
+ set.call(ed, ed.value.slice(0, pos - count) + text + ed.value.slice(pos))
355
+ ed.selectionStart = ed.selectionEnd = pos - count + Math.max(1, text.length - 1)
356
+ ed.dispatchEvent(new Event('input', { bubbles: true }))
357
+ return true
358
+ }
359
+ var at = caretPos(ed)
360
+ var before = textOf(ed)
361
+ if (at < count || !setSelectionRange(ed, at - count, at)) return false
362
+ insertText(ed, text)
363
+ // 宿主可能吃掉这次 insertText(实测 Lexical 对 2 字符串会静默不落字):
364
+ // 按整段文本复核,没落地就交回给原来的配对路径,别让开关变成「点了没反应」。
365
+ var want = before.slice(0, at - count) + text + before.slice(at)
366
+ if (textOf(ed) !== want) {
367
+ if (setSelectionRange(ed, at, at)) { /* 复原光标,让调用方继续走老路 */ }
368
+ return false
369
+ }
370
+ placeCaretVerified(ed, at - count + Math.max(1, text.length - 1), want)
371
+ return true
372
+ }
373
+
374
+ function setSelectionRange(ed, start, end) {
375
+ try {
376
+ var sel = window.getSelection()
377
+ if (!sel) return false
378
+ var walker = document.createTreeWalker(ed, 4, null)
379
+ var acc = 0, node, startNode = null, startOff = 0, endNode = null, endOff = 0
380
+ while ((node = walker.nextNode())) {
381
+ var len = (node.nodeValue || '').length
382
+ if (startNode === null && acc + len >= start) { startNode = node; startOff = Math.max(0, start - acc) }
383
+ if (acc + len >= end) { endNode = node; endOff = Math.max(0, end - acc); break }
384
+ acc += len
385
+ }
386
+ var r = document.createRange()
387
+ if (startNode && endNode) { r.setStart(startNode, startOff); r.setEnd(endNode, endOff) }
388
+ else { r.selectNodeContents(ed); r.collapse(false) }
389
+ sel.removeAllRanges(); sel.addRange(r)
390
+ return true
391
+ } catch (e) { return false }
392
+ }
393
+
394
+ function caretChar(ed, dir) {
395
+ try {
396
+ if (isText(ed)) {
397
+ var i = ed.selectionStart
398
+ return dir > 0 ? ed.value[i] || '' : ed.value[i - 1] || ''
399
+ }
400
+ var off = caretOffset(ed)
401
+ if (off < 0) return ''
402
+ var t = flatText(ed)
403
+ return dir > 0 ? (t[off] || '') : (t[off - 1] || '')
404
+ } catch (e) { return '' }
405
+ }
406
+ function selectBack(ed, n) { // 把 caret 前 n 个字符变成选区(补全替换 token 用)
407
+ try {
408
+ if (isText(ed)) { ed.selectionStart = Math.max(0, ed.selectionStart - n); return }
409
+ var off = caretOffset(ed)
410
+ if (off >= 0) setSelectionRange(ed, Math.max(0, off - n), off)
411
+ } catch (e) {}
412
+ }
413
+ /** 环绕选区:把选中内容用一对符号框起来,光标落在闭符之后(框完继续打字接在闭符后面)。
414
+ * 插入只能走 **paste 通道**(合成 ClipboardEvent)—— `insertText` 拼好的多行文本会被 Lexical 吃掉,
415
+ * 连续两次 execCommand 也会被它的重渲染清成最后一个字符。没被接管时才退回 execCommand。 */
416
+ /* 上一次框选的范围:用于「再打一次同一个引号 = 取消这次框选」()。 */
417
+
418
+ // ── 模块:配对与框选 ────────────────────────────────
419
+ // wrapSelection / repairWrapDup / tryUnwrap / surroundSelection:把选区框起来、以及框完的反选与清理。
420
+ // 本文件是 lib/client.js 的片段,由 tools/build-client.mjs 按序拼回;不要直接改生成物 lib/client.js。
421
+
422
+ var lastWrap = null
423
+
424
+ /* 框选只是"一个动作":期间不许弹补全。
425
+ * 实测原因:Lexical 不认 preventDefault,会先把选区替成那一个字符,插件随后用 paste 补全;
426
+ * 这中间的 input 事件足以让自动补全弹出来 —— 也就是说的"暴露第一个字然后进行提醒"。 */
427
+ function wrapSelection(ed, open, selTxt, close) {
428
+ var from = textBeforeCaret(ed).length
429
+ var baseLen = textOf(ed).length
430
+ var prevSuppress = suppressInput
431
+ suppressInput = prevSuppress + 1
432
+ closePopup()
433
+ try {
434
+ surroundSelection(ed, open, selTxt, close)
435
+ } finally {
436
+ suppressInput = prevSuppress // 只压住自己这一次插入,不泄漏给下一键
437
+ var start = from + open.length
438
+ lastWrap = { ed: ed, open: open, close: close, start: start,
439
+ end: start + String(selTxt || '').length, inner: String(selTxt || '') }
440
+ // 落地后按真文本复核(实测:全角 “ 包裹会多出一个开符 ——
441
+ // Lexical 不认 preventDefault,粘贴之外它又按自己的键插了一遍;
442
+ // 于是 Ctrl+Z 撤掉的是那个多余引号,而不是把原文还回来)。
443
+ // 多拍复核:Lexical 那次"按自己的键再插一遍"落在插件第一次复核之后(实测),
444
+ // 所以按 0/16/48/120/240ms 各看一次,命中一次就收(修复是幂等的)。
445
+ var wantLen = baseLen + open.length + close.length
446
+ // 多行/带空格的包裹走 execCommand 兜底时会被吃掉一部分(实测:围栏里选中整段再框,
447
+ // 落地的是「开符 + 内容」,闭符没了)。所以落定后复核一次,缺哪一侧就补哪一侧。
448
+ ;[0, 16, 48, 120, 240].forEach(function (ms) {
449
+ setTimeout(function () { repairWrapDup(ed, open, String(selTxt || ''), close, wantLen) }, ms)
450
+ })
451
+ }
452
+ }
453
+
454
+ /* 「反选」:框完再打同一个引号 → 去掉两侧、内层重新选中。文本被别人改过就不认。
455
+ * deleteAt(ed, off) 只删一个字符,从后往前删,开符那一侧的偏移不会被挪动。 */
456
+
457
+ /** 包裹后按真文本复核:把「不在 open+内层+close 里」的多余开符删掉。
458
+ * 实测 全角 “ 包裹时 Lexical 会按自己的模型再插一个开符,而且**落在文首**
459
+ * (选中 abcd 的 1-3 → `“a“bc”d`),原来的判断只认紧挨着的 `open+open`,所以漏了。 */
460
+ function repairWrapDup(ed, open, inner, close, wantLen) {
461
+ try {
462
+ var t = textOf(ed)
463
+ // 长度不变量:包裹只在选区两侧各加一个字符。文本没变长就说明没有多余开符,
464
+ // 一旦动手就会删掉正确的那一个(半角中文那例就是这么被删坏的)。
465
+ if (typeof wantLen === 'number' && t.length <= wantLen) return
466
+ var want = open + inner + close
467
+ var at = t.indexOf(want)
468
+ if (at < 0) return
469
+ for (var i = 0; i < t.length; i++) {
470
+ if (t.charAt(i) !== open) continue
471
+ if (i >= at && i < at + open.length) continue // 正确那个开符
472
+ deleteAt(ed, i) // 落在地上的多余开符
473
+ t = textOf(ed)
474
+ at = t.indexOf(want)
475
+ break
476
+ }
477
+ if (at >= 0) {
478
+ var endAt = at + want.length
479
+ if (t.charAt(endAt - 1) === close) placeCaretVerified(ed, endAt, t)
480
+ lastWrap && (lastWrap.start = at + open.length)
481
+ }
482
+ } catch (e) {}
483
+ }
484
+
485
+ function tryUnwrap(ed, open) {
486
+ if (!lastWrap || lastWrap.ed !== ed || lastWrap.open !== open) return false
487
+ if (open !== lastWrap.close) return false
488
+ var w = lastWrap
489
+ var text = textOf(ed)
490
+ var start = w.start
491
+ var exact = text.slice(w.start - 1, w.start) === open
492
+ && text.slice(w.end, w.end + 1) === w.close
493
+ && text.slice(w.start, w.end) === w.inner
494
+ if (!exact) {
495
+ // 位置口径在不同编辑器里不一致(textBeforeCaret 可能按选区起点也可能按末端),
496
+ // 所以对不上时按「开符 + 内层 + 闭符」在全文里找一次;找不到才算被改过。
497
+ var at = text.indexOf(open + w.inner + w.close)
498
+ if (at < 0) { lastWrap = null; return false }
499
+ start = at + open.length
500
+ }
501
+ lastWrap = null
502
+ var innerEnd = start + w.inner.length
503
+ // 一次替换,不留给编辑器中间态:选中 open+inner+close,用 inner 覆盖
504
+ if (isText(ed)) {
505
+ ed.selectionStart = start - 1
506
+ ed.selectionEnd = innerEnd + 1
507
+ } else {
508
+ setSelectionRange(ed, start - 1, innerEnd + 1)
509
+ }
510
+ insertText(ed, w.inner)
511
+ if (isText(ed)) {
512
+ ed.selectionStart = start - 1
513
+ ed.selectionEnd = start - 1 + w.inner.length
514
+ } else {
515
+ soon(function () { setSelectionRange(ed, start - 1, start - 1 + w.inner.length) })
516
+ }
517
+ return true
518
+ }
519
+
520
+ /** 多行包裹的第二步:确认「开符 + 内容 + 闭符」齐全,缺闭符就补一个(单字符插入)。
521
+ * 幂等:已经齐了就什么都不做;找不到「开符 + 内容」也放手,不猜位置。 */
522
+ function finishMultilineWrap(ed, open, inner, close) {
523
+ try {
524
+ var t = textOf(ed)
525
+ var want = open + inner + close
526
+ if (t.indexOf(want) >= 0) return
527
+ var head = open + inner
528
+ var at = t.indexOf(head)
529
+ if (at < 0) return
530
+ var pos = at + head.length
531
+ if (t.charAt(pos) === close) return
532
+ setSelectionRange(ed, pos, pos)
533
+ suppressInput++
534
+ insertText(ed, close)
535
+ placeCaretVerified(ed, pos + close.length, t.slice(0, pos) + close + t.slice(pos))
536
+ } catch (e) {}
537
+ }
538
+ function surroundSelection(ed, open, selTxt, close) {
539
+ try {
540
+ if (!isText(ed)) {
541
+ var sel = window.getSelection()
542
+ if (!sel || !sel.rangeCount) return
543
+ var inner = String(selTxt || sel.toString() || '')
544
+ var text = open + inner + close
545
+ // 含换行的选区(两段以上、代码围栏整段)分两步包裹:
546
+ // ① 80ms 后贴「开符 + 内容」—— 同拍贴整段时宿主会把**末尾的引号**吃掉(只丢引号,不丢别的字符);
547
+ // ② 再等 400ms 核对,缺闭符就用单字符插入补上(那时宿主已落定,单字符插入稳定)。
548
+ // 判据:两段文本两侧引号齐全、每次一致;撤销两拍回到原句(单段仍是一拍)。
549
+ if (String(inner).indexOf('\n') >= 0) {
550
+ setTimeout(function () {
551
+ try {
552
+ var selNow = window.getSelection()
553
+ if (!selNow || !selNow.rangeCount || !String(selNow)) return
554
+ var dtM = new DataTransfer()
555
+ dtM.setData('text/plain', open + String(inner))
556
+ ed.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dtM, bubbles: true, cancelable: true }))
557
+ setTimeout(function () { finishMultilineWrap(ed, open, String(inner), close) }, 400)
558
+ } catch (eM) {}
559
+ }, 80)
560
+ return
561
+ }
562
+ var tailLen = 0
563
+ try {
564
+ var r0 = sel.getRangeAt(0)
565
+ var afterR = document.createRange()
566
+ afterR.selectNodeContents(ed)
567
+ afterR.setStart(r0.endContainer, r0.endOffset)
568
+ tailLen = textLenOf(afterR.cloneContents())
569
+ } catch (e5) { tailLen = 0 }
570
+ var handled = false
571
+ try {
572
+ var dt = new DataTransfer()
573
+ dt.setData('text/plain', text)
574
+ var evPaste = new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })
575
+ handled = ed.dispatchEvent(evPaste) === false // false = 被 preventDefault = 编辑器接下了
576
+ } catch (e3) {}
577
+ dbg('wrap-paste', { ed: ed, sel: inner, handled: handled })
578
+ // 兜底只在**宿主没接**时走 execCommand;它含空格会静默不落字,落没落由 wrapSelection 复核
579
+ if (!handled) insertText(ed, text)
580
+ soon(function () { // 等 Lexical 落定,再把光标放到闭符之后
581
+ try {
582
+ var at = flatText(ed).length - tailLen
583
+ if (at >= 0) setSelectionRange(ed, at, at)
584
+ } catch (e4) {}
585
+ })
586
+ return
587
+ }
588
+ insertText(ed, open + selTxt + close)
589
+ var endAt = ed.selectionStart // 文本插入完成后 caret 在整段之后
590
+ ed.selectionStart = ed.selectionEnd = endAt // 就在这里:闭符之后
591
+ } catch (e) {}
592
+ }
593
+
594
+ // 弹层:一个 fixed div,数据来自 words 路由
595
+
596
+ // ── 模块:补全弹层 ────────────────────────────────
597
+ // 弹层 DOM、候选渲染与行样式、光标定位、类型图标、以及本文词(contextWords)的收集。
598
+ // 本文件是 lib/client.js 的片段,由 tools/build-client.mjs 按序拼回;不要直接改生成物 lib/client.js。
599
+
600
+ var popup = { root: null, items: [], index: 0, open: false, sig: '', wm: null }
601
+ function ensurePopup() {
602
+ if (popup.root) return popup.root
603
+ // 同一页面可能被装了两份插件(本地路径 + GitHub 各一份):复用已有的同一个元素,
604
+ // 否则两套实例各画一个弹层,就成了「一个在输入框左上、一个在光标后」的两个框(实测)。
605
+ var exist = document.getElementById('dsh-cp-popup')
606
+ if (exist) { popup.root = exist; return exist }
607
+ var d = document.createElement('div')
608
+ d.id = 'dsh-cp-popup'
609
+ d.setAttribute('data-dsh-plugin', 'dsh-composer-ease')
610
+ d.style.cssText = 'position:fixed;z-index:2147480000;display:none;overflow-y:auto;overflow-x:hidden;' +
611
+ 'border:1px solid var(--dsw-alias-border-l2,#333);border-radius:8px;' +
612
+ 'background:var(--dsw-alias-bg-layer-2,#1e1e1e);color:var(--dsw-alias-label-primary,inherit);' +
613
+ 'font:var(--dsw-font-xs,12.5px)/1.6 ui-monospace,Consolas,monospace;box-shadow:0 8px 24px rgba(0,0,0,.35)'
614
+ document.body.appendChild(d)
615
+ popup.root = d
616
+ return d
617
+ }
618
+ /** 光标(caret)位置:弹层锚在光标上,而不是输入框左边缘 —— 否则一行长文时弹层会盖住半个视野。
619
+ * 只读地量,不往宿主 DOM 里塞临时节点(那会扰动 Lexical)。 */
620
+ function caretRect(ed) {
621
+ try {
622
+ if (!isText(ed)) {
623
+ var sel = window.getSelection()
624
+ if (sel && sel.rangeCount) {
625
+ var r = sel.getRangeAt(0).cloneRange()
626
+ var rects = r.getClientRects()
627
+ if (rects && rects.length) return rects[rects.length - 1]
628
+ var b = r.getBoundingClientRect()
629
+ if (b && (b.width || b.height || b.top)) return b
630
+ }
631
+ }
632
+ } catch (e) {}
633
+ return ed.getBoundingClientRect()
634
+ }
635
+ function itemsSignature(items) {
636
+ var out = []
637
+ for (var i = 0; i < items.length; i++) out.push(items[i].kind + ':' + items[i].value)
638
+ return out.join('|')
639
+ }
640
+ function escapeHtml(s) {
641
+ return String(s).replace(/[&<>"]/g, function (c) { return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c] })
642
+ }
643
+ /** 命中字符加粗 —— VS Code 的建议列表就是这么标的(位置由服务端 fuzzyScore 给)。 */
644
+ function boldHtml(label, matches) {
645
+ var out = '', at = 0, s = String(label)
646
+ for (var i = 0; i < matches.length; i++) {
647
+ var idx = matches[i]
648
+ if (idx < at || idx >= s.length) continue
649
+ if (idx > at) out += escapeHtml(s.slice(at, idx))
650
+ out += '<b>' + escapeHtml(s[idx]) + '</b>'
651
+ at = idx + 1
652
+ }
653
+ if (at < s.length) out += escapeHtml(s.slice(at))
654
+ return out
655
+ }
656
+ /** 列表右侧那个淡淡的类型词(VS Code 也只在右侧挂一个短类型)。 */
657
+ function kindLabel(it) {
658
+ if (it.src === 'context') return '上下文'
659
+ if (it.src === 'composer') return '本文'
660
+ if (it.kind === 'file') return '文件'
661
+ if (it.kind === 'model') return '模型'
662
+ if (it.kind === 'snippet') return '片段'
663
+ var d = String(it.detail || '')
664
+ if (d.indexOf('标签') >= 0) return '元素'
665
+ if (d.indexOf('属性') >= 0) return '属性'
666
+ if (d.indexOf('关键字') >= 0) return '关键字'
667
+ if (d.indexOf('词表') >= 0) return '词表'
668
+ return ''
669
+ }
670
+ /** 类型图标(VS Code 的建议框左侧就是图标;小方块 + 首字,纯 SVG,不用图片资源)。 */
671
+ function kindIcon(it) {
672
+ var label = kindLabel(it)
673
+ var color = it.kind === 'file' ? '#7aa2f7' : (it.src === 'context' ? '#9ece6a' : (it.kind === 'model' ? '#e0af68' : '#9aa5b1'))
674
+ var ch = label ? label.slice(0, 1) : '·'
675
+ return '<svg width="12" height="12" viewBox="0 0 12 12" aria-hidden="true">' +
676
+ '<rect x="0.5" y="0.5" width="11" height="11" rx="2.5" fill="none" stroke="' + color + '" stroke-opacity="0.55"/>' +
677
+ '<text x="6" y="8.6" text-anchor="middle" font-size="7" fill="' + color + '">' + escapeHtml(ch) + '</text></svg>'
678
+ }
679
+
680
+ /** 只取**对话正文区**,不取整页(改)。
681
+ * 宿主给的锚点在 0.2.0-rc.2 的 dsh-client-ui-chat 里实测有两处:
682
+ * `[data-conversation-scroll]` 与 `[data-slot="conversation.chat.node"]`。
683
+ * 为什么不读 document.body:左侧栏与设置面板里的词不是「对话内容」,算进候选既跑题,
684
+ * 又会把弹层自己画出来的候选再喂回池子(自我喂养)。抓不到就回空串 —— 上层自然退回只看草稿,
685
+ * 不抛错、不往宿主 DOM 里写任何东西。 */
686
+ function conversationText() {
687
+ try {
688
+ var box = document.querySelector ? document.querySelector('[data-conversation-scroll]') : null
689
+ if (!box || !box.innerText) {
690
+ var node = document.querySelector ? document.querySelector('[data-slot="conversation.chat.node"]') : null
691
+ box = node ? (node.parentNode || null) : null
692
+ }
693
+ var txt = box && box.innerText ? String(box.innerText) : ''
694
+ return txt.length > 40 ? txt.slice(-12000) : ''
695
+ } catch (e) { return '' }
696
+ }
697
+
698
+ /** 上下文候选:把**对话正文区**里的标识符挖出来(只读,不动宿主 DOM)。
699
+ * 这是「提示内容跟上下文强关联」的来源:对话里出现过的词,打前缀就能补出来。2 秒缓存。 */
700
+ var ctxCache = { text: '', words: [], at: 0 }
701
+ function contextWords(ed) {
702
+ try {
703
+ var now = Date.now()
704
+ if (now - ctxCache.at < 2000 && ctxCache.words.length) return ctxCache.words
705
+ // 只吃对话正文(不裁剪:实测用 split(草稿) 抠自己人时,
706
+ // 「inst」是「install_quarantine」的子串,会把上下文里的词劈坏)。自己人的重复交给去重处理。
707
+ var text = conversationText()
708
+ var counts = {}, m
709
+ // 汉字词也收(2~8 个连续汉字算一个词):「文档」「变量」这类中文词不进池子,
710
+ // 光靠英文标识符就永远补不出来(指出)。
711
+ var re = /[A-Za-z_][A-Za-z0-9_]{3,}|[\u4e00-\u9fff]+/g
712
+ var CJK_STOP = { 我们: 1, 你们: 1, 他们: 1, 这个: 1, 那个: 1, 一个: 1, 什么: 1, 怎么: 1,
713
+ 因为: 1, 所以: 1, 然后: 1, 如果: 1, 就是: 1, 可以: 1, 没有: 1, 已经: 1,
714
+ 还是: 1, 但是: 1, 而且: 1, 现在: 1 }
715
+ var STOP = { this: 1, that: 1, with: 1, from: 1, your: 1, have: 1, will: 1, that: 1, there: 1, their: 1, what: 1,
716
+ when: 1, then: 1, than: 1, they: 1, been: 1, were: 1, void: 1, true: 1, false: 1, null: 1 }
717
+ while ((m = re.exec(text)) !== null) {
718
+ var w = m[0]
719
+ var low = w.toLowerCase()
720
+ if (STOP[low] || CJK_STOP[w]) continue
721
+ // 汉字串整个跳过:没有分词器时切出来的 n 字块会把整句贴回弹层;中文候选交给服务端词典。
722
+ if (/^[\u4e00-\u9fff]+$/.test(w)) continue
723
+ counts[w] = (counts[w] || 0) + 1
724
+ }
725
+ var words = Object.keys(counts).sort(function (a, b) { return counts[b] - counts[a] || (a < b ? -1 : 1) }).slice(0, 120)
726
+ ctxCache = { text: text, words: words, at: now }
727
+ return words
728
+ } catch (e) { return [] }
729
+ }
730
+
731
+ /** 候选选中行:可见的淡蓝底 + 左侧强调条(VS Code 的 activeSelection 观感)。 */
732
+ var SEL_STYLE = 'background:rgba(86,140,224,.22) !important;box-shadow:inset 2px 0 0 0 #4c8dff;'
733
+
734
+ /** 显示层折叠(一次反馈):
735
+ * harness/依赖目录里的文件不展开长路径,只显示 `dsh: <文件名>`;其它类型原样。
736
+ * 只影响**显示**,插进正文的仍是完整路径(it.value)。 */
737
+ var HARNESS_LABEL = /(deepseek[-_]harness|dsh-(fixes|kit|shim|020rc2|017)|node_modules|(^|\/)plugins\/)/i
738
+ function foldLabel(it) {
739
+ var v = String(it.value || '')
740
+ if (it.kind !== 'file' || !HARNESS_LABEL.test(v)) return v
741
+ var base = v.slice(v.lastIndexOf('/') + 1)
742
+ return 'dsh: ' + base
743
+ }
744
+
745
+ /* 标红画在**文字底下**(每一行的文字框底边),不是输入框底边:
746
+ * 输入框底边离文字约 27px,看起来像给整个框描一道线,和「这个词有问题」无关。
747
+ * 用固定定位的覆盖层逐行画,不动宿主 DOM(Lexical 会重建节点、也会把行内样式冲掉)。 */
748
+ var warnLayer = null, warnKey = ''
749
+ function ensureWarnLayer() {
750
+ if (warnLayer && warnLayer.parentNode) return warnLayer
751
+ var exist = document.getElementById('dce-quote-warn-layer')
752
+ if (exist) { warnLayer = exist; return exist }
753
+ var d = document.createElement('div')
754
+ d.id = 'dce-quote-warn-layer'
755
+ d.setAttribute('data-dsh-plugin', 'dsh-composer-ease')
756
+ d.setAttribute('aria-hidden', 'true')
757
+ d.style.cssText = 'position:fixed;left:0;top:0;width:0;height:0;pointer-events:none;z-index:2147479990'
758
+ document.body.appendChild(d)
759
+ warnLayer = d
760
+ return d
761
+ }
762
+ function paintQuoteWarn(ed, idx) {
763
+ try {
764
+ var layer = ensureWarnLayer()
765
+ if (idx === undefined || idx < 0) { if (layer.childNodes.length) layer.innerHTML = ''; warnKey = ''; return }
766
+ var box = composerOf(ed) || ed
767
+ // 只量**那一个字符**的盒子:一行里只有这个引号没闭合,画整行会让人以为整句都错了。
768
+ var rect = null
769
+ var walker = document.createTreeWalker(box, 4 /* SHOW_TEXT */, null)
770
+ var node, acc = 0
771
+ while ((node = walker.nextNode())) {
772
+ var len = (node.nodeValue || '').length
773
+ if (idx < acc + len) {
774
+ var r = document.createRange()
775
+ var at = idx - acc
776
+ r.setStart(node, at)
777
+ r.setEnd(node, Math.min(len, at + 1))
778
+ var rects = r.getClientRects()
779
+ if (rects && rects.length) rect = rects[0]
780
+ break
781
+ }
782
+ acc += len
783
+ }
784
+ if (!rect) { if (layer.childNodes.length) layer.innerHTML = ''; return }
785
+ var sig = Math.round(rect.left) + ',' + Math.round(rect.bottom) + ',' + Math.round(rect.width)
786
+ if (sig === warnKey) return
787
+ warnKey = sig
788
+ layer.innerHTML = ''
789
+ var wave = document.createElement('div')
790
+ wave.style.cssText = 'position:fixed;left:' + Math.round(rect.left) + 'px;top:' + Math.round(rect.bottom - 3) + 'px;' +
791
+ 'width:' + Math.max(6, Math.round(rect.width)) + 'px;height:4px;background-repeat:repeat-x;background-position:left top;' +
792
+ 'background-size:8px 4px;background-image:url("data:image/svg+xml;utf8,<svg xmlns=\'http://www.w3.org/2000/svg\' width=\'8\' height=\'4\'><path d=\'M0 3 Q2 0 4 3 T8 3\' fill=\'none\' stroke=\'%23e5534b\' stroke-width=\'1.2\'/></svg>")'
793
+ layer.appendChild(wave)
794
+ } catch (e) {}
795
+ }
796
+ function renderPopup(anchor, force) {
797
+ var d = ensurePopup()
798
+ var sig = itemsSignature(popup.items) + '#' + (popup.wm ? popup.wm.text + popup.wm.angle : '')
799
+ if (!force && popup.open && sig === popup.sig) return // 列表没变就别重画:打字快时不再抖
800
+ popup.sig = sig
801
+ d.innerHTML = ''
802
+ // 语言水印(定的口径):不再在顶上写「据对话推断 · java」那种句子,
803
+ // 只把**语言词表的语言名**做成底纹压在右下角,斜排、透明;语言越多越斜,封顶 45°。
804
+ // 纯装饰:不吃点击、不进无障碍树,没有语言时整块不渲染。
805
+ if (popup.wm && popup.wm.text) {
806
+ var wm = document.createElement('div')
807
+ wm.setAttribute('data-dsh-cp-wm', '1')
808
+ wm.setAttribute('aria-hidden', 'true')
809
+ wm.style.cssText = 'position:absolute;right:6px;bottom:2px;pointer-events:none;user-select:none;' +
810
+ 'transform:rotate(-' + popup.wm.angle + 'deg);transform-origin:right bottom;' +
811
+ 'font-size:18px;font-weight:700;letter-spacing:.04em;line-height:1;white-space:nowrap;' +
812
+ 'opacity:.15;color:var(--dsw-alias-label-primary,currentColor)'
813
+ wm.textContent = popup.wm.text
814
+ d.appendChild(wm)
815
+ }
816
+ popup.items.forEach(function (it, i) {
817
+ var row = document.createElement('div')
818
+ var label = foldLabel(it)
819
+ var m = (it.matches && it.matches.length) ? it.matches : null
820
+ // 布局照 VS Code:左边类型图标、中间标签(命中字符加粗)、**右边**淡类型词——
821
+ // 类型信息一律在右侧,不会跑到候选名前面去(提的排版问题)。
822
+ row.innerHTML = '<span style="flex:0 0 auto;line-height:0">' + kindIcon(it) + '</span>' +
823
+ '<span style="flex:1 1 auto;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' +
824
+ (m ? boldHtml(label, m) : escapeHtml(label.length > 60 ? label.slice(0, 57) + '…' : label)) + '</span>' +
825
+ '<span style="flex:0 0 auto;opacity:.4;font-size:11px;font-weight:400;margin-left:8px">' + escapeHtml(kindLabel(it)) + '</span>'
826
+ // 选中态自己给一套看得见的样式:主题的 interactive-bg-hover 实测是 rgba(38,49,72,.06),
827
+ // 深色背景下等于没有(上下键改了选中也看不出来)。
828
+ row.style.cssText = 'display:flex;align-items:center;gap:6px;padding:3px 10px;cursor:pointer;' +
829
+ (i === popup.index ? SEL_STYLE : '')
830
+ // 用**被点这一行**的下标接受:早先只读 popup.index(靠悬停更新),
831
+ // 没有 hover 时点任何一行都等于接受第一条(报「点击提示框没反应」)。
832
+ row.onmousedown = function (ev) { ev.preventDefault(); popup.index = i; acceptCurrent() }
833
+ // 悬停只换高亮:早先这里调 renderPopup 重建整层,鼠标一进去脚下的节点就被换掉,
834
+ // mousedown 落不到稳定节点 → 「点击提示框没反应」()。
835
+ row.onmouseenter = function () {
836
+ if (popup.index === i) return
837
+ popup.index = i
838
+ showGhost(anchor) // 悬停换选中,行内预览同步(与方向键同一口径)
839
+ var kids = d.children
840
+ for (var kk = 0; kk < kids.length; kk++) {
841
+ kids[kk].style.cssText = kids[kk].style.cssText.split(SEL_STYLE).join('') + (kk === i ? SEL_STYLE : '')
842
+ }
843
+ }
844
+ d.appendChild(row)
845
+ })
846
+ if (anchor) {
847
+ var caret = caretRect(anchor)
848
+ var vw = window.innerWidth, vh = window.innerHeight
849
+ // 宽度固定一档(不随候选长短抖),高度封顶,超出滚动 —— VSCode 建议框也是这个路子
850
+ var width = Math.round(Math.min(520, Math.max(300, vw * 0.36)))
851
+ var maxH = Math.round(Math.min(260, Math.max(140, vh * 0.3)))
852
+ d.style.width = width + 'px'
853
+ d.style.maxWidth = 'none'
854
+ d.style.maxHeight = maxH + 'px'
855
+ d.style.left = Math.max(8, Math.min(Math.round(caret.left), vw - width - 8)) + 'px'
856
+ var roomBelow = vh - Math.round(caret.bottom)
857
+ if (roomBelow >= maxH + 24) { // 下面放得下就放下边(VSCode:优先光标下方)
858
+ d.style.top = Math.round(caret.bottom + 6) + 'px'
859
+ d.style.bottom = 'auto'
860
+ } else { // 放不下才翻到上方,并贴住光标那一行
861
+ d.style.bottom = Math.max(8, vh - Math.round(caret.top) + 6) + 'px'
862
+ d.style.top = 'auto'
863
+ }
864
+ }
865
+ d.style.display = 'block'
866
+ popup.open = true
867
+ }
868
+ /* 行内提示(VSCode 的 inline suggestion 手感,原话「可以在前面添加类似 abc 的提示」):
869
+ * 首位候选比已打的前缀多出来的那截,用灰字贴在光标后面;不往宿主 DOM 里插节点,只浮一个 div。 */
870
+ var ghost = { el: null }
871
+ function ensureGhost() {
872
+ if (ghost.el) return ghost.el
873
+ var existG = document.getElementById('dsh-cp-ghost')
874
+ if (existG) { ghost.el = existG; return existG }
875
+ var d = document.createElement('div')
876
+ d.id = 'dsh-cp-ghost'
877
+ d.setAttribute('data-dsh-plugin', 'dsh-composer-ease')
878
+ d.style.cssText = 'position:fixed;z-index:2147479999;display:none;pointer-events:none;white-space:pre;' +
879
+ 'color:var(--dsw-alias-label-secondary,rgba(165,175,190,.8));opacity:.8'
880
+ document.body.appendChild(d)
881
+ ghost.el = d
882
+ return d
883
+ }
884
+ function hideGhost() { if (ghost.el) ghost.el.style.display = 'none' }
885
+ function showGhost(ed, token) {
886
+ if (!popup.open) return hideGhost() // 浮层已经收了就别再出灰字(回车后迟到的响应会走到这里)
887
+ try {
888
+ // **跟着选中项走**(一次反馈)。
889
+ // 早先固定取 items[0],所以上下键改了选中、行内灰字不动 —— VS Code 的 suggest.preview 是随选中变化的。
890
+ var it = popup.items[popup.index] || popup.items[0]
891
+ var v = it ? String(it.value || '') : ''
892
+ // token 缺省时从光标处现取:悬停/方向键这两条路手上没有 prefix 参数。
893
+ var t = String(token == null ? tokenBefore(textBeforeCaret(ed)) : token)
894
+ if (!t || !v || v.slice(0, t.length).toLowerCase() !== t.toLowerCase()) return hideGhost()
895
+ var rest = v.slice(t.length)
896
+ if (!rest || rest.length > 48) return hideGhost()
897
+ var d = ensureGhost()
898
+ var r = caretRect(ed)
899
+ d.textContent = rest
900
+ try { d.style.font = window.getComputedStyle(ed).font } catch (e) {}
901
+ d.style.left = Math.round(r.left) + 'px'
902
+ d.style.top = Math.round(r.top) + 'px'
903
+ d.style.display = 'block'
904
+ } catch (e) { hideGhost() }
905
+ }
906
+
907
+ /* 「弹层代数」:每次 closePopup 就 +1。
908
+ * 一次反馈——
909
+ * 真因是候选请求的 .then 在回车清场**之后**才回来,把浮层与灰字又画了一遍。
910
+ * 发请求那一刻记下代数,响应回来对不上就直接丢弃,别去猜"还能不能画"。 */
911
+ var popupEpoch = 0
912
+ function closePopup() { popupEpoch++; if (popup.root) popup.root.style.display = 'none'; popup.open = false; popup.items = []; popup.sig = ''; lastSelText = ''; hideGhost() }
913
+ var lastPrefix = null, inflight = 0
914
+
915
+ /** 草稿里已有的单词(VSCode word based suggestions 的口径:标识符形状、长度≥3、按出现次数排)。 */
916
+
917
+ // ── 模块:语言判定与候选拉取 ────────────────────────────────
918
+ // docWords、语言投票与语言水印、fetchCandidates(本地词先上屏、网络回来再合并)。
919
+ // 本文件是 lib/client.js 的片段,由 tools/build-client.mjs 按序拼回;不要直接改生成物 lib/client.js。
920
+
921
+ function docWords(text, token) {
922
+ var t = String(token || '').toLowerCase()
923
+ if (!t) return []
924
+ var counts = {}, m
925
+ // 英文标识符 + 连续汉字词(2~8 字)都收;普通长串再补一个 2 字前缀,
926
+ // 这样「文档里的变量」里也能提出「文档」(要的「像提示变量那样」)。
927
+ var re = /[A-Za-z_][A-Za-z0-9_]{2,}|[\u4e00-\u9fff]+/g
928
+ var CJKONLY = /^[\u4e00-\u9fff]+$/
929
+ while ((m = re.exec(String(text || ''))) !== null) {
930
+ var w = m[0]
931
+ if (w.toLowerCase() === t) continue
932
+ // 汉字串不在这里切片:客户端没有分词器,切出来的 n 字块既可能是句子片段,也得靠一个
933
+ // 写死的长度上限兜着。中文候选一律交给服务端的词典(lexiconCandidates),边界由数据定。
934
+ if (CJKONLY.test(w)) continue
935
+ counts[w] = (counts[w] || 0) + 1
936
+ }
937
+ return Object.keys(counts)
938
+ .filter(function (w) { return w.toLowerCase().indexOf(t) === 0 })
939
+ .sort(function (a, b) { return counts[b] - counts[a] || a.length - b.length || (a < b ? -1 : 1) })
940
+ .slice(0, 5)
941
+ .map(function (w) {
942
+ var pos = []
943
+ for (var i = 0; i < t.length; i++) pos.push(i) // 前缀命中:把已打的那几个字符加粗
944
+ return { kind: 'word', value: w, detail: '本文', src: 'composer', matches: pos }
945
+ })
946
+ }
947
+
948
+ /** 草稿里最近一个围栏标记(```js / ```python)当语言提示;没有就交给服务端上公共词表。 */
949
+ function langHint(text) {
950
+ var m = String(text || '').match(/```([A-Za-z0-9+#._-]{1,12})/g)
951
+ return m && m.length ? m[m.length - 1].slice(3) : ''
952
+ }
953
+
954
+ function composerText(ed) {
955
+ try { return isText(ed) ? ed.value : String((composerOf(ed) || ed).innerText || '') } catch (e) { return '' }
956
+ }
957
+
958
+ /** 语言投票:返回语言 + 票数 + 命中明细。
959
+ * 同一份投票既决定「上哪张词表」,也决定弹层顶上那行出处(
960
+ * 「编程语言要和上下文有关联、并让人看见依据」)。票数低于 2 一律判不定。 */
961
+ var LANG_RULES = [
962
+ ['python', /\b(def|import|from|elif|lambda|None|True|False|self)\b/g, 1],
963
+ ['python', /\.py\b|\bpip\b|\bpytest\b|\bvenv\b/g, 2],
964
+ ['javascript', /\b(const|let|var|function|=>|async|await|module\.exports|require)\b/g, 1],
965
+ ['javascript', /\.(js|mjs|ts|tsx|json)\b|\bnpm\b|\bnode\b/g, 2],
966
+ ['html', /<\/?[a-z][a-z0-9-]*\b|\bclass=|\bid=|<\/div>|\b<!doctype\b/gi, 2],
967
+ ['css', /\b(color|display|margin|padding|font-size|background)\s*:|\{[^}]*:[^}]*\}/g, 1],
968
+ ['java', /\b(public|private|protected|void|static|implements|extends|new|String)\b|@Override|System\.out|String\[\]/g, 1]
969
+ ]
970
+ function inferLangVote(text) {
971
+ var t = String(text || '')
972
+ if (!t) return { lang: '', top: 0, hits: {} }
973
+ var score = {}, hits = {}
974
+ for (var i = 0; i < LANG_RULES.length; i++) {
975
+ var m = t.match(LANG_RULES[i][1])
976
+ if (!m) continue
977
+ score[LANG_RULES[i][0]] = (score[LANG_RULES[i][0]] || 0) + m.length * LANG_RULES[i][2]
978
+ hits[LANG_RULES[i][0]] = (hits[LANG_RULES[i][0]] || 0) + m.length
979
+ }
980
+ var best = '', top = 0, ranking = []
981
+ for (var k in score) {
982
+ if (score[k] > top) { top = score[k]; best = k }
983
+ if (score[k] >= 2) ranking.push({ lang: k, score: score[k] })
984
+ }
985
+ ranking.sort(function (a, b) { return b.score - a.score || (a.lang < b.lang ? -1 : 1) })
986
+ return { lang: top >= 2 ? best : '', top: top, hits: hits, ranking: ranking }
987
+ }
988
+
989
+ /** 语言水印(口径):只写**语言名**,不写「据对话推断」这类句子;
990
+ * 斜角随语言种数增长、封顶 45°(1→14°、2→26°、3→38°、4 及以上→45°)。没有语言就返回 null。 */
991
+ function watermarkFor(langs) {
992
+ var list = (langs || []).filter(Boolean).slice(0, 4)
993
+ if (!list.length) return null
994
+ return { text: list.join(' · '), angle: Math.min(45, 14 + 12 * (list.length - 1)) }
995
+ }
996
+
997
+ /** 从**上下文文本**(草稿 + 对话正文)推语言:围栏之外的第二条腿。 */
998
+ function inferLang(text) { return inferLangVote(text).lang }
999
+
1000
+ function fetchCandidates(prefix, ed) {
1001
+ var draft = composerText(ed)
1002
+ var prefixList = [String(prefix || '')]
1003
+ if (/^[\u4e00-\u9fff]+$/.test(prefixList[0])) {
1004
+ for (var pl2 = 4; pl2 >= 2; pl2--) if (prefixList[0].length >= pl2) prefixList.push(prefixList[0].slice(-pl2))
1005
+ }
1006
+ var local = [], seenDraft = {}
1007
+ for (var di = 0; di < prefixList.length; di++) {
1008
+ var batch = docWords(draft, prefixList[di])
1009
+ for (var bi = 0; bi < batch.length; bi++) {
1010
+ var key = batch[bi].value.toLowerCase()
1011
+ if (seenDraft[key]) continue
1012
+ seenDraft[key] = 1
1013
+ local.push(batch[bi])
1014
+ }
1015
+ if (local.length >= 6) break // 短前缀够用了就别再往更短的找
1016
+ }
1017
+ var fenced = langHint(draft)
1018
+ if (fenced) habitBump(fenced, 1)
1019
+ var ctxWords = contextWords(ed)
1020
+ // 投票用**对话原文**(不是去重后的词表):词表是集合,`System.out` / `@Override` 这种
1021
+ // 带标点的语言指纹在切词时就被劈掉了,而且词频信息也丢了。原文截尾 4000 字够用。
1022
+ var ctxRaw = String(ctxCache.text || '').slice(-4000)
1023
+ // 语言优先级:围栏 → 上下文推断(草稿 + 对话正文)→ 习惯
1024
+ var vote = inferLangVote(draft + ' ' + ctxRaw)
1025
+ var lang = fenced || vote.lang || habitTop()
1026
+ // 语言水印(替代原来顶上那行「据对话推断 · java」):只写语言名,斜排压在右下角。
1027
+ // 围栏时只报围栏那一个语言;否则报投票过线的语言,按票数从高到低,最多 4 个。
1028
+ var wmLangs = fenced ? [fenced]
1029
+ : (vote.ranking && vote.ranking.length ? vote.ranking.map(function (r) { return r.lang })
1030
+ : (lang ? [lang] : []))
1031
+ popup.wm = watermarkFor(wmLangs)
1032
+ // 上下文候选:对话里出现过的标识符按前缀补出来(跟上下文强关联的那一路)
1033
+ // 中文没有分词:整段汉字会被当成一个 token,所以按「1~4 字后缀」各试一次(长的优先),
1034
+ // 打「文」也能命中草稿里更早出现的「文档」(原话要求)。
1035
+ var p0 = String(prefix || '')
1036
+ var pfxList = [p0]
1037
+ if (/^[\u4e00-\u9fff]+$/.test(p0)) {
1038
+ for (var pl = 4; pl >= 2; pl--) if (p0.length >= pl) pfxList.push(p0.slice(-pl))
1039
+ }
1040
+ var ctxItems = []
1041
+ var seenLocal = {}
1042
+ local.forEach(function (i) { seenLocal[i.value.toLowerCase()] = 1 })
1043
+ ctxWords.forEach(function (w) {
1044
+ if (ctxItems.length >= 5) return
1045
+ var lw = w.toLowerCase()
1046
+ if (seenLocal[lw]) return
1047
+ for (var qi = 0; qi < pfxList.length; qi++) {
1048
+ var q = pfxList[qi].toLowerCase()
1049
+ if (!q || lw === q || lw.indexOf(q) !== 0) continue
1050
+ seenLocal[lw] = 1
1051
+ var pos = []
1052
+ for (var k = 0; k < q.length; k++) pos.push(k)
1053
+ ctxItems.push({ kind: 'word', value: w, detail: '上下文', src: 'context', matches: pos })
1054
+ return
1055
+ }
1056
+ })
1057
+ // ctx words lead only for prefix >= 3 (or CJK): a 2-char prefix must not be hijacked
1058
+ // by a word that merely appeared in the draft (measured: de led with DeepSeek).
1059
+ var ctxLead = /^[\u4e00-\u9fff]/.test(String(prefix || '')) || String(prefix || '').length >= 3
1060
+ var first = ctxLead ? local.concat(ctxItems) : local.slice()
1061
+ var cjkPrefix = /^[\u4e00-\u9fff]/.test(String(prefix || ''))
1062
+ // 中文要等服务端的「完整词义」闸:碎片连本地词都不许先上屏(否则会闪一下又不算数)
1063
+ if (first.length && !cjkPrefix) { popup.items = first; popup.index = 0; renderPopup(ed) }
1064
+ inflight++
1065
+ var seq = inflight
1066
+ var ep = popupEpoch // 发请求时的代数:期间被关掉(回车发送/失焦/碎片闸)就作废这次响应
1067
+ var draftTail = composerText(ed).slice(-800)
1068
+ fetch(ROUTE_WORDS + '?prefix=' + encodeURIComponent(prefix || '') + '&lang=' + encodeURIComponent(lang) +
1069
+ '&draft=' + encodeURIComponent(draftTail))
1070
+ .then(function (r) { return r.json() })
1071
+ .then(function (d) {
1072
+ if (seq !== inflight) return // 后发先至的旧响应丢掉(补全弹层最怕错位)
1073
+ if (ep !== popupEpoch) return // 迟到的响应不许把已经收起来的浮层/灰字复活
1074
+ // 服务端判定这不是完整词义(碎片)→ 整条收起,连本地/上下文候选都不出
1075
+ if (d && d.wordGate === false) { closePopup(); return }
1076
+ var items = local.slice(), seen = {}
1077
+ local.forEach(function (i) { seen[i.value] = 1 })
1078
+ // 同一规则作用在 fetch 返回后的最终合并上(首屏那次只是占位):短前缀下 ctx 词
1079
+ // 排在服务端候选**之后**,否则 DeepSeek 这类草稿词还是会顶掉 def/delete。
1080
+ var ctxList = ctxLead ? ctxItems : []
1081
+ ctxList.forEach(function (c) { if (!seen[c.value]) { seen[c.value] = 1; items.push(c) } })
1082
+ if (d && d.ok === true) (d.candidates || []).forEach(function (c) { if (c && !seen[c.value]) { seen[c.value] = 1; items.push(c) } })
1083
+ if (!ctxLead) ctxItems.forEach(function (c) { if (!seen[c.value]) { seen[c.value] = 1; items.push(c) } })
1084
+ // 类型开关:在**合并这一处**滤掉,后面的索引 / 灰字 / Tab 都跟着同一份列表
1085
+ items = items.filter(function (x) { return isKindOn(x.kind) })
1086
+ if (!items.length) { closePopup(); return }
1087
+ popup.items = items.slice(0, 12); popup.index = 0; renderPopup(ed); showGhost(ed, prefix)
1088
+ })
1089
+ .catch(function () { if (ep === popupEpoch && !first.length) closePopup() })
1090
+ }
1091
+
1092
+ // ── 模块:设置项(严格配对 / 引号模式 / 候选类型预设)──────────────────
1093
+ // 从 70-input.js 拆出来的(加完候选类型预设后那个片段到 411 行,超结构闸的 400 上限)。
1094
+ // 只放「存哪儿 + 怎么读 + 预设怎么切」,不碰事件处理。
1095
+
1096
+ var PAIR_STRICT_KEY = 'dsh-composer-ease:pairStrict'
1097
+ function pairStrict() {
1098
+ try { return window.localStorage.getItem(PAIR_STRICT_KEY) === '1' } catch (e) { return false }
1099
+ }
1100
+ function setPairStrict(on) {
1101
+ try { window.localStorage.setItem(PAIR_STRICT_KEY, on ? '1' : '0') } catch (e) {}
1102
+ }
1103
+
1104
+ /* 引号模式():'warn' = 不自动补闭引号,未闭合时标红提示(VS Code 的
1105
+ * 「标红而不是硬改」口径,默认);'auto' = 老行为,打一个引号补一对。 */
1106
+ /* 候选类型开关(等价 VS Code `suggest.show*` 一族里插件有的四类):
1107
+ * 存「关掉哪些」,默认全开;过滤在候选合并那一处做,索引/灰字/Tab 都跟着同一份列表。 */
1108
+ var KIND_KEY = 'dsh-composer-ease:kinds-off'
1109
+ var ALL_KINDS = [['word', '词'], ['file', '文件'], ['model', '模型'], ['snippet', '片段']]
1110
+ function kindsOff() {
1111
+ try { var a = JSON.parse(window.localStorage.getItem(KIND_KEY) || '[]'); return Array.isArray(a) ? a : [] } catch (e) { return [] }
1112
+ }
1113
+ function isKindOn(k) { return kindsOff().indexOf(k) < 0 }
1114
+ function toggleKind(k) {
1115
+ var off = kindsOff(), i = off.indexOf(k)
1116
+ if (i >= 0) off.splice(i, 1); else off.push(k)
1117
+ try { window.localStorage.setItem(KIND_KEY, JSON.stringify(off)) } catch (e) {}
1118
+ }
1119
+
1120
+ /* 候选类型**预设**:不把四个开关摊成四行(`/` 菜单里太费),一行循环切换。
1121
+ * 组合仍是四个 kind 的开关,所以手改 localStorage 的 kinds-off 依然有效。 */
1122
+ var KIND_PRESETS = [
1123
+ { label: '全部', off: [] },
1124
+ { label: '无文件', off: ['file'] },
1125
+ { label: '只有词', off: ['file', 'snippet', 'model'] },
1126
+ { label: '全关', off: ['word', 'file', 'model', 'snippet'] },
1127
+ ]
1128
+ function kindPresetIndex() {
1129
+ var off = kindsOff().slice().sort().join(',')
1130
+ for (var i = 0; i < KIND_PRESETS.length; i++) {
1131
+ if (KIND_PRESETS[i].off.slice().sort().join(',') === off) return i
1132
+ }
1133
+ return -1 // 手工组合:不属于任何预设
1134
+ }
1135
+ function kindPresetLabel() {
1136
+ var i = kindPresetIndex()
1137
+ return i >= 0 ? KIND_PRESETS[i].label : '自定义'
1138
+ }
1139
+ function kindPresetDesc() {
1140
+ var i = kindPresetIndex()
1141
+ if (i < 0) return '自定义组合(' + ALL_KINDS.filter(function (k) { return isKindOn(k[0]) }).map(function (k) { return k[1] }).join('+') + ')'
1142
+ return i === 0 ? '词 + 文件 + 模型 + 片段 全开'
1143
+ : i === 1 ? '词 + 模型 + 片段(文件路径不进候选)'
1144
+ : i === 2 ? '只留词(词典 / 关键字 / 本机词)'
1145
+ : '四类全关(等于关掉补全)'
1146
+ }
1147
+ function kindPresetNextLabel() {
1148
+ var i = kindPresetIndex()
1149
+ return KIND_PRESETS[(i + 1) % KIND_PRESETS.length].label
1150
+ }
1151
+ function cycleKindPreset() {
1152
+ var next = KIND_PRESETS[(kindPresetIndex() + 1) % KIND_PRESETS.length]
1153
+ try { window.localStorage.setItem(KIND_KEY, JSON.stringify(next.off)) } catch (e) {}
1154
+ }
1155
+
1156
+ var QUOTE_MODE_KEY = 'dsh-composer-ease:quoteMode'
1157
+ // 全角括号改半角(默认关):中文输入法会把 ( 合成 (,有人要的就是 ASCII 的 ()。
1158
+ // 关=尊重输入法打出来的字符(中文正文里 () 才是正确标点)。
1159
+ var FW_HALF_KEY = 'dsh-composer-ease:fullwidthHalf'
1160
+ // 默认开:中文态打 ( 出来的 ( 直接换成半角 ()(一次反馈:括号会输出成())。
1161
+ // 想按中文标点写正文的,在 / 菜单关掉即可。
1162
+ function fwHalf() {
1163
+ try { return window.localStorage.getItem(FW_HALF_KEY) !== '0' } catch (e) { return true }
1164
+ }
1165
+ function setFwHalf(on) {
1166
+ try { window.localStorage.setItem(FW_HALF_KEY, on ? '1' : '0') } catch (e) {}
1167
+ }
1168
+ // 默认 auto:没有选区、前方也没有未闭合引号时,单独打一个引号就该得到一对。
1169
+ // 标红模式退成显式开关(/ 菜单里切),不再默认关掉配对。
1170
+ function quoteMode() {
1171
+ try { return window.localStorage.getItem(QUOTE_MODE_KEY) === 'warn' ? 'warn' : 'auto' } catch (e) { return 'auto' }
1172
+ }
1173
+ function setQuoteMode(m) {
1174
+ try { window.localStorage.setItem(QUOTE_MODE_KEY, m === 'auto' ? 'auto' : 'warn') } catch (e) {}
1175
+ }
1176
+ // ── 设置版本:默认值改过之后,旧默认值不能一直粘在用户身上 ──────────────
1177
+ // 实测:旧版默认是「标红提示」,那些用户 localStorage 里存着 warn,升级后打引号
1178
+ // 仍然不配对,看上去像插件没修好。换默认值时清掉该格,显式选择从这一版重新记。
1179
+ var SCHEMA_KEY = 'dsh-composer-ease:schema'
1180
+ var SCHEMA_NOW = '2'
1181
+ function settingsKeys() { return [PAIR_STRICT_KEY, KIND_KEY, QUOTE_MODE_KEY, FW_HALF_KEY] }
1182
+ function migrateSettings() {
1183
+ try {
1184
+ if (window.localStorage.getItem(SCHEMA_KEY) === SCHEMA_NOW) return
1185
+ window.localStorage.removeItem(QUOTE_MODE_KEY)
1186
+ window.localStorage.setItem(SCHEMA_KEY, SCHEMA_NOW)
1187
+ } catch (e) {}
1188
+ }
1189
+ function resetSettings() {
1190
+ try {
1191
+ for (var i = 0; i < settingsKeys().length; i++) window.localStorage.removeItem(settingsKeys()[i])
1192
+ window.localStorage.setItem(SCHEMA_KEY, SCHEMA_NOW)
1193
+ } catch (e) {}
1194
+ }
1195
+
1196
+ /* ── / 菜单:三行设置什么时候出、命令名怎么补 ───────────────────────
1197
+ * 宿主那侧只用子序列打分(dsh-web-frontend 里的 rankByName):/comp 出 compact,
1198
+ * 但 /compot 这种错拼的子序列会断(整组只剩本插件那三行)。
1199
+ * 所以:① 三行也按 query 过滤,别在宿主那组消失后还赖在菜单里;
1200
+ * ② 错拼的兜底行由宿主的命令表来补(rescueRows),插件自己不解命令。 */
1201
+ function looseScore(query, text) {
1202
+ var q = String(query || '').toLowerCase(), t = String(text || '').toLowerCase()
1203
+ if (!q) return 0
1204
+ if (!t) return -1
1205
+ if (t.indexOf(q) === 0) return 1000 - Math.min(999, t.length - q.length) // 前缀命中
1206
+ var at = t.indexOf(q)
1207
+ if (at > 0) return 800 - Math.min(799, at) // 中段连续命中
1208
+ var ti = 0, hit = 0
1209
+ for (var qi = 0; qi < q.length; qi++) {
1210
+ var ch = q.charAt(qi)
1211
+ while (ti < t.length && t.charAt(ti) !== ch) ti++
1212
+ if (ti >= t.length) return -1 // 子序列也断:不命中
1213
+ ti++; hit++
1214
+ }
1215
+ return 400 + hit
1216
+ }
1217
+ /** 错拼容忍:正常命中一律不算(返回 -1),只有「跳过 ≤2 个查询字符、≤2 个目标字符」
1218
+ * 才给分数。实测 /compot → compact 恰好是跳 1 个查询字符(o)、2 个目标字符(a c)。 */
1219
+ function typoScore(query, text) {
1220
+ var q = String(query || '').toLowerCase(), t = String(text || '').toLowerCase()
1221
+ if (!q || !t || looseScore(q, t) >= 0) return -1
1222
+ var qi = 0, ti = 0, skipQ = 0, skipT = 0, hit = 0
1223
+ while (qi < q.length) {
1224
+ if (ti >= t.length) { skipQ += q.length - qi; break }
1225
+ if (q.charAt(qi) === t.charAt(ti)) { qi++; ti++; hit++; continue }
1226
+ var ahead = t.indexOf(q.charAt(qi), ti)
1227
+ if (ahead >= 0 && ahead - ti <= 2 && skipT + (ahead - ti) <= 2) { skipT += ahead - ti; ti = ahead; continue }
1228
+ skipQ++; qi++
1229
+ if (skipQ > 2) return -1
1230
+ }
1231
+ return hit >= 3 ? hit : -1
1232
+ }
1233
+ /** 三条设置行(定的口径:/ 菜单是操作入口不是设置面板,一行一条、点一下切一档)。 */
1234
+ function settingsRows() {
1235
+ var on = pairStrict()
1236
+ var qm = quoteMode()
1237
+ return [
1238
+ {
1239
+ name: '引号模式',
1240
+ label: qm === 'auto' ? '引号模式:自动补对' : '引号模式:标红提示',
1241
+ description: qm === 'auto'
1242
+ ? '当前:自动补对(打一个引号补一对;严格配对关时右侧有文字则只插一个)'
1243
+ : '当前:标红提示(默认;引号未闭合时输入框标红,不替你补)',
1244
+ hint: qm === 'auto' ? '点击改为标红提示' : '点击改为自动补对',
1245
+ },
1246
+ { name: '候选类型',
1247
+ label: '候选类型:' + kindPresetLabel(),
1248
+ description: '当前:' + kindPresetDesc(),
1249
+ hint: '点击切到:' + kindPresetNextLabel() },
1250
+ {
1251
+ name: '全角括号',
1252
+ label: '全角括号:' + (fwHalf() ? '打成半角 ()' : '保持全角()'),
1253
+ description: fwHalf()
1254
+ ? '当前:输入法打 ( 得到半角 ()(写正文想要中文标点就关掉这一项)'
1255
+ : '当前:保持输入法给的字符(中文正文里 () 是正确标点)',
1256
+ hint: fwHalf() ? '点击改为保持全角' : '点击改为打成半角 ()',
1257
+ },
1258
+ {
1259
+ name: '重置插件设置',
1260
+ label: '重置插件设置',
1261
+ description: '把本插件存下的选择(引号模式 / 候选类型 / 严格配对)清回默认',
1262
+ hint: '点击立即重置',
1263
+ },
1264
+ {
1265
+ name: '严格配对',
1266
+ label: '严格配对',
1267
+ description: on ? '当前:开(引号必须前后成对,混全角半角也算一对)' : '当前:关(默认,遇到已有引号就不再补对)',
1268
+ hint: on ? '点击关闭' : '点击开启',
1269
+ },
1270
+ ]
1271
+ }
1272
+ /** 取不到宿主命令表时的兜底:本机只有这三条内置命令(dsh-command-compact/goal/feedback)。 */
1273
+ var BUILTIN_COMMANDS = [['compact', '压缩上下文'], ['goal', '目标'], ['feedback', '反馈']]
1274
+ /** 错拼兜底行:宿主那份排名没排出来的命令,按「跳字」判据补一行(正常拼写不重复出)。 */
1275
+ function rescueRows(query, host, leading) {
1276
+ var q = String(query || '')
1277
+ if (!q) return []
1278
+ var matched = {}
1279
+ var seen = host && host.matched ? host.matched : []
1280
+ for (var i = 0; i < seen.length; i++) if (seen[i] && seen[i].name) matched[seen[i].name] = 1
1281
+ var pool = (host && host.all && host.all.length) ? host.all : BUILTIN_COMMANDS.map(function (c) {
1282
+ return { name: c[0], label: c[1] }
1283
+ })
1284
+ var out = []
1285
+ for (var j = 0; j < pool.length; j++) {
1286
+ var r = pool[j]
1287
+ if (!r || !r.name || matched[r.name]) continue // 宿主已经排出来的行不再重复
1288
+ // 与宿主同一条位置判据:带输入提示的命令只在行首列(行内打 / 时不列)
1289
+ if (!leading && r.hint) continue
1290
+ var score = Math.max(typoScore(q, r.name), typoScore(q, r.label || ''))
1291
+ if (score < 0) continue
1292
+ out.push({
1293
+ name: r.name,
1294
+ label: '/' + r.name + (r.label ? ' ' + r.label : ''),
1295
+ description: String(r.description || ''),
1296
+ hint: '错拼兜底',
1297
+ rescue: r.name,
1298
+ })
1299
+ if (out.length >= 3) break
1300
+ }
1301
+ return out
1302
+ }
1303
+ function triggerBasename(rel) {
1304
+ var t = String(rel || '')
1305
+ var i = t.lastIndexOf('/')
1306
+ return i >= 0 ? t.slice(i + 1) : t
1307
+ }
1308
+
1309
+ /** 直接问一次只读路由,拿原始候选(不做 DOM 渲染,供触发源用)。 */
1310
+ function fetchItems(prefix) {
1311
+ var url = ROUTE_WORDS + '?prefix=' + encodeURIComponent(prefix || '') + '&lang='
1312
+ return fetch(url).then(function (r) { return r.json() }).then(function (d) {
1313
+ return d ? (Array.isArray(d.candidates) ? d.candidates : (Array.isArray(d.items) ? d.items : [])) : []
1314
+ }).catch(function () { return [] })
1315
+ }
1316
+
1317
+ // ── 模块:光标复位与接受 ────────────────────────────────
1318
+ // placeCaretVerified / flushCaretFix / afterPairCheck / ensureCaretAt:插入后把光标放回该在的位置,以及 acceptCurrent。
1319
+ // 本文件是 lib/client.js 的片段,由 tools/build-client.mjs 按序拼回;不要直接改生成物 lib/client.js。
1320
+
1321
+ var suppressInput = 0 // 我们自己插入文本后会派发 input;别让它回来又开一次弹层(连按即滚雪球)
1322
+ var caretEpoch = 0 // 每次按键 +1;延后的光标复位只认最后一次
1323
+ /* IME 合成中:拼音还没上屏,这时候的拉丁字母不是「输入内容」,不能触发补全。
1324
+ * 实测:打拼音 `ye` 时 JS 关键字 `yield` 被前缀命中,弹层+灰字 `eld`
1325
+ * 盖在输入法候选窗旁边。VS Code 同样在 composition 期间不出建议。 */
1326
+ var composing = false
1327
+ /* 光标复位要**跨帧校验**:实测(demo 3081)配对后只调一次 `setSelectionRange`
1328
+ * 会被 Lexical 随后的一次重渲染冲掉 —— 空框打 `"` 拿到 `""` 但 caret 停在 0(开符之前),
1329
+ * 于是接着打字打到引号外面、连按第三下也被当成「跳过闭符」吞掉。
1330
+ * 这里按绝对偏移写几次,每次都读回来确认;期间只要又按了键(caretEpoch 变)就立刻放手,
1331
+ * 不去跟用户抢光标。文本域是同步的,写一次就返回。 */
1332
+ /* 光标复位:时机由 **Lexical 的 DOM 变化**来定,不能猜 —— 插件插入的文本节点在 microtask / rAF /
1333
+ * `setTimeout 0` 那一刻都还不存在,约 2ms 后才出现。盲目撒定时器两头出错:等不到那一格会被输入法
1334
+ * 上屏抢先(文字落到闭符外面),长尾回调又会在用户打完字后把光标拽回原位。
1335
+ * 做法:MutationObserver 等文本落地,再用**文本护栏**(`expect`)判断有没有人插手。 */
1336
+ var caretFix = null
1337
+ var caretObserver = null, caretObserverTarget = null
1338
+ function ensureCaretObserver(ed) {
1339
+ if (typeof MutationObserver === 'undefined') return
1340
+ try {
1341
+ if (caretObserver && caretObserverTarget === ed) return
1342
+ if (caretObserver) caretObserver.disconnect()
1343
+ caretObserver = new MutationObserver(function () { flushCaretFix() })
1344
+ caretObserver.observe(ed, { childList: true, subtree: true, characterData: true })
1345
+ caretObserverTarget = ed
1346
+ } catch (e) { caretObserver = null }
1347
+ }
1348
+ function flushCaretFix() {
1349
+ var f = caretFix
1350
+ if (!f) return
1351
+ if (f.epoch !== caretEpoch) { caretFix = null; return } // 期间有按键/输入法接管:放手
1352
+ if (f.tries === undefined) f.tries = 0
1353
+ var now
1354
+ try { now = textOf(f.ed) } catch (e) { return }
1355
+ if (now !== f.expect) {
1356
+ if (now.length < f.expect.length) return // 自己的插入还没落地,等下一次 mutation
1357
+ caretFix = null; return // 文本被别人改过:不猜,放手
1358
+ }
1359
+ var at = -1
1360
+ try {
1361
+ if (caretPos(f.ed) !== f.target) setCaret(f.ed, f.target)
1362
+ at = caretPos(f.ed)
1363
+ } catch (e) { at = -1 }
1364
+ /* **要连续两拍都读到目标位置、而且文本没再变**才算落地:
1365
+ * Lexical 的重渲染比插件的写入晚一拍(实测 ~2ms),只写一次会被它冲掉 ——
1366
+ * 那正是「空框打全角开符,配对后光标停在闭符之后」的实测值来源。 */
1367
+ if (at === f.target && f.lastText === now) f.stable = (f.stable || 0) + 1
1368
+ else f.stable = 0
1369
+ f.lastText = now
1370
+ if (f.stable >= 2) {
1371
+ caretFix = null
1372
+ if (typeof f.then === 'function') { try { f.then() } catch (e) {} } // 落地后的复核
1373
+ return
1374
+ }
1375
+ if (++f.tries < 10) soon(flushCaretFix)
1376
+ else caretFix = null
1377
+ }
1378
+ /** 配对之后的**结果复核**:按「落地后的真文本」把判据重算一遍。
1379
+ * 读到的状态可能是旧的(Lexical 重渲染的空窗),但结果不能跟着旧走 ——
1380
+ * 本该「只插一个」的,就把多出来的闭符撤掉。判据在真文本上成立时这个函数什么都不做
1381
+ * (这正是正常路径),只在读到旧状态时才纠正。 */
1382
+ function afterPairCheck(ed, open, close, atOpen) {
1383
+ return function () {
1384
+ try {
1385
+ var t = textOf(ed)
1386
+ if (t.slice(atOpen, atOpen + 2) !== open + close) return // 已经不是我们插的那一对了
1387
+ var b = t.slice(0, atOpen), n = t.charAt(atOpen + 2)
1388
+ if (!isUnbalancedBefore(b, open, close) && shouldPair(b, n, open)) return
1389
+ suppressInput++
1390
+ deleteAt(ed, atOpen + 1) // 撤掉闭符 → 只剩一个开符
1391
+ placeCaretVerified(ed, atOpen + 1, t.slice(0, atOpen + 1) + t.slice(atOpen + 2))
1392
+ ensureCaretAt(ed, atOpen + 1)
1393
+ } catch (e) {}
1394
+ }
1395
+ }
1396
+ /** 插一段文本:优先用粘贴事件(Lexical 把它当一次原子编辑,落点稳定),不行再 execCommand。
1397
+ * 专门给"配对"这种紧跟下一键的场景用。 */
1398
+ function insertAtomic(ed, text) {
1399
+ var handled = false
1400
+ try {
1401
+ var dt = new DataTransfer()
1402
+ dt.setData('text/plain', text)
1403
+ var evPaste = new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })
1404
+ handled = ed.dispatchEvent(evPaste) === false
1405
+ } catch (e) { handled = false }
1406
+ if (!handled) insertText(ed, text)
1407
+ }
1408
+
1409
+ /** 读**实际**光标在编辑器纯文本里的偏移;读不到回 -1。 */
1410
+ function caretOffsetNow(ed) {
1411
+ try {
1412
+ if (isText(ed)) return ed.selectionStart
1413
+ var sel = window.getSelection()
1414
+ if (!sel || !sel.rangeCount) return -1
1415
+ var r = sel.getRangeAt(0)
1416
+ if (!r.collapsed) return -1
1417
+ var pre = document.createRange()
1418
+ pre.selectNodeContents(ed)
1419
+ pre.setEnd(r.startContainer, r.startOffset)
1420
+ return textLenOf(pre.cloneContents())
1421
+ } catch (e) { return -1 }
1422
+ }
1423
+
1424
+ /** 把光标按**实际偏移**钉到 target:不对就硬设,再按梯子复验几拍。
1425
+ * 配对那条路不能只信"预期偏移"——编辑器可能自己挪过光标(全角引号就是这种情况)。 */
1426
+ function ensureCaretAt(ed, target, tries) {
1427
+ var left = tries === undefined ? 3 : tries
1428
+ var step = function (n) {
1429
+ if (target < 0) return
1430
+ if (caretOffsetNow(ed) === target) return
1431
+ try { setSelectionRange(ed, target, target) } catch (e) {}
1432
+ // 让编辑器(Lexical)把模型选区也同步过来,否则下一键会按它自己的模型落字
1433
+ try { document.dispatchEvent(new Event('selectionchange')) } catch (e2) {}
1434
+ if (n > 0) soon(function () { step(n - 1) })
1435
+ }
1436
+ step(left)
1437
+ }
1438
+
1439
+ /** 把光标放到 target,但要等 `expect` 这段文本落地之后才动手。
1440
+ * `expect` = 插件自己这一次插入完成后的**整段文本**;文本长度更短=还没落地(继续等),
1441
+ * 内容对不上=别人(用户/输入法)插手了(放手,绝不抢光标)。
1442
+ * `then` = 落地后跑一次的复核。 */
1443
+ function placeCaretVerified(ed, target, expect, then) {
1444
+ if (target < 0) return
1445
+ var epoch = ++caretEpoch
1446
+ if (isText(ed)) {
1447
+ try { ed.selectionStart = ed.selectionEnd = target } catch (e) {}
1448
+ if (typeof then === 'function') { try { then() } catch (e) {} }
1449
+ return
1450
+ }
1451
+ caretFix = { ed: ed, target: target, expect: String(expect === undefined ? '' : expect), epoch: epoch, then: then }
1452
+ ensureCaretObserver(ed)
1453
+ flushCaretFix() // 文本已经在位的情况(跳闭符那条路没有文本变化)
1454
+ soon(function () { // 兜底:万一等不到 mutation
1455
+ if (caretFix && caretFix.epoch === caretEpoch) flushCaretFix()
1456
+ })
1457
+ }
1458
+ /** 片段占位符的待跳表:接受片段后填上,Tab 依次跳,任何其它按键/失焦清空。 */
1459
+ var snippetStops = null, snippetEpoch = 0
1460
+
1461
+ /* 把光标前 [start, 光标) 这一段换成 text,并**核对结果**。
1462
+ * 一次反馈:接受后原文被吞掉、候选没进去,只能 Ctrl+Z 找回。
1463
+ * 真因是 contenteditable 这条路把「选 token → 插候选」交给 execCommand,而宿主 Lexical
1464
+ * 有时不认插件摆的选区(实测:打 文档 再 Tab 得到「文档管理文档」,多一份原文;
1465
+ * 另有一次候选一个字都没落进去)。所以:落一次 → 比整段文本 → 不对就 undo 这一笔,
1466
+ * 原文原地留着 —— 接受要么正确,要么什么都没发生。 */
1467
+ function spliceDraft(ed, start, text) {
1468
+ var before = textOf(ed)
1469
+ var caret = caretPos(ed)
1470
+ var end = caret >= 0 ? caret : before.length
1471
+ if (start < 0 || start > end) start = end
1472
+ var expected = before.slice(0, start) + text + before.slice(end)
1473
+ var multi = String(text).indexOf('\n') >= 0
1474
+ suppressInput++
1475
+ if (isText(ed)) {
1476
+ try { ed.selectionStart = start; ed.selectionEnd = end } catch (e) {}
1477
+ insertRich(ed, text)
1478
+ return textOf(ed) === expected
1479
+ }
1480
+ try {
1481
+ setSelectionRange(ed, start, end)
1482
+ insertRich(ed, text) // 多行走 paste 通道,单行走 execCommand
1483
+ if (textOf(ed) === expected) return true
1484
+ if (multi) { // 宿主的 paste 是异步落的:给它一拍再核
1485
+ soon(function () {
1486
+ try { if (textOf(ed) !== expected) document.execCommand('undo') } catch (e2) {}
1487
+ })
1488
+ return true
1489
+ }
1490
+ if (before !== textOf(ed)) { try { document.execCommand('undo') } catch (e3) {} }
1491
+ return false
1492
+ } catch (e) { return false }
1493
+ }
1494
+
1495
+ function acceptCurrent() {
1496
+ var it = popup.items[popup.index]
1497
+ var ed = lastComposer
1498
+ if (!it || !ed) { closePopup(); return }
1499
+ if (it.lang) habitBump(it.lang, 2) // 接受过哪套词表的候选,就算在用哪门语言
1500
+ // token 必须在**接受这一拍**现算:lastToken 是弹层触发时的快照,
1501
+ // 打字比弹层快时会过期(实测:打 console.log 只回删了 'log',前面的 'console.' 留在原地)。
1502
+ var tok = tokenBefore(textBeforeCaret(ed)) || lastToken || ''
1503
+ var caret0 = caretPos(ed)
1504
+ var start0 = (caret0 >= 0 && tok) ? Math.max(0, caret0 - tok.length) : caret0
1505
+ var payload = String(it.kind === 'file' ? String(it.value).replace(/\\/g, '/') : it.value)
1506
+ var stops = []
1507
+ if (it.kind === 'snippet') {
1508
+ var ex = expandSnippet(payload)
1509
+ payload = ex.text
1510
+ stops = ex.stops
1511
+ }
1512
+ snippetStops = null
1513
+ // 没落成就把原文留着、弹层也留着(换一条再来),Tab 不会看起来像没反应
1514
+ if (!spliceDraft(ed, start0, payload)) return
1515
+ closePopup()
1516
+ if (stops.length && start0 >= 0) {
1517
+ // 落点基准是**替换区间的起点**,不是插入前的光标位置(早先按光标算,光标会落在文本末尾)
1518
+ snippetStops = stops.map(function (s) { return start0 + s })
1519
+ snippetEpoch = caretEpoch
1520
+ // 先同步落一次(异步观察者赶不上下一拍),再用 placeCaretVerified 兜住编辑器把光标甩到末尾的情况
1521
+ try { setSelectionRange(ed, snippetStops[0], snippetStops[0]) } catch (e) {}
1522
+ placeCaretVerified(ed, snippetStops[0])
1523
+ // 再补一拍:Lexical 往往在插入落地后才把光标甩到末尾,placeCaretVerified 的观察者赶不上它
1524
+ var stop0 = snippetStops[0]
1525
+ soon(function () {
1526
+ if (!snippetStops || snippetStops[0] !== stop0) return
1527
+ try { setSelectionRange(ed, stop0, stop0) } catch (e) {}
1528
+ })
1529
+ }
1530
+ }
1531
+
1532
+ var lastComposer = null, lastToken = '', lastSelText = ''
1533
+
1534
+ /* 语言习惯:观察实际在用什么语言(围栏标记、以及接受过哪套词表的候选),
1535
+ * 存本地,下一次没写围栏时就按习惯上词表 —— 原话「通过观察用户操作习惯选择使用哪个语言」。 */
1536
+ var HABIT_KEY = 'dsh-composer-ease:lang-habit'
1537
+ function habitRead() { try { return JSON.parse(window.localStorage.getItem(HABIT_KEY) || '{}') || {} } catch (e) { return {} } }
1538
+ function habitBump(lang, weight) {
1539
+ if (!lang) return
1540
+ try {
1541
+ var h = habitRead(); h[lang] = (h[lang] || 0) + (weight || 1)
1542
+ window.localStorage.setItem(HABIT_KEY, JSON.stringify(h))
1543
+ } catch (e) {}
1544
+ }
1545
+ function habitTop() {
1546
+ var h = habitRead(), best = '', n = 0
1547
+ for (var k in h) { if (h[k] > n) { best = k; n = h[k] } }
1548
+ return best
1549
+ }
1550
+
1551
+ /* 调试开关(默认关):localStorage['dsh-composer-ease:debug']='1' 后刷新页面即开。
1552
+ * 存在的唯一理由:一次反馈在本机 Playwright 上复现不出来
1553
+ * (造不出真实输入法的 composition 事件),所以把实测上那几个字段原样打出来。 */
1554
+ var DBG = (function () {
1555
+ try { return window.localStorage.getItem('dsh-composer-ease:debug') === '1' } catch (e) { return false }
1556
+ })()
1557
+ function dbg(kind, o) {
1558
+ if (!DBG) return
1559
+ try {
1560
+ var ed = o && o.ed
1561
+ var t = ed ? textOf(ed) : ''
1562
+ var sel = ed ? [caretOffset(ed), selectedText(ed)] : ['-', '-']
1563
+ console.log('[cpe] ' + kind, JSON.stringify({
1564
+ key: o.key, data: o.data, itype: o.itype, composing: !!o.composing,
1565
+ before: o.before, sel: sel[1], caret: sel[0], text: t,
1566
+ action: o.action, prevented: !!o.prevented
1567
+ }))
1568
+ } catch (e) {}
1569
+ }
1570
+
1571
+ // ── 模块:键位层 ────────────────────────────────
1572
+ // onKeyDown(配对、跳过闭符、空对删除、方向键选候选)与两个删除动作。
1573
+ // 本文件是 lib/client.js 的片段,由 tools/build-client.mjs 按序拼回;不要直接改生成物 lib/client.js。
1574
+
1575
+ function onKeyDown(ev) {
1576
+ var ed = composerOf(ev.target)
1577
+ if (!ed) { if (popup.open) closePopup(); return }
1578
+ lastComposer = ed
1579
+ if (composing) return // IME 合成期间不接管任何键(拼音的字母要原样交给输入法)
1580
+ /* 读状态之前,先把**上一键挂起的光标复位**就地落地(幂等:文本不匹配就什么都不做)。
1581
+ * 不这么做,下一键读到的就是旧光标位置 —— 空框打 `"` 得 `""` 之后手快再打 `"`,
1582
+ * 因为光标还没挪到两符中间,第二下会当成字面插入 → `"""`(一次反馈正是这一拍)。 */
1583
+ flushCaretFix()
1584
+ if (window.__dceSyntheticBackspace && ev.key === 'Backspace') return
1585
+ caretEpoch++ // 新按键作废上一键挂起的光标复位(否则旧回调会把光标拨回去)
1586
+ var k = { key: ev.key, code: ev.code, ctrl: ev.ctrlKey || ev.metaKey, shift: ev.shiftKey,
1587
+ __popupOpen: popup.open, __collapsedFalse: !!(ev.target && ev.target === ed && selectedText(ed)),
1588
+ __nextIsCloser: caretChar(ed, +1) === ev.key }
1589
+ dbg('keydown', { ed: ed, key: ev.key, before: textBeforeCaret(ed), composing: composing })
1590
+ // 记下这一键按下时的选区:IME/全角那条路 keydown 拿不到字符,靠 input 事件回头补框选
1591
+ lastSelText = selectedText(ed)
1592
+ // 回车=发送:先把弹层与灰字收干净,别让它们挂在被清空的输入框上。
1593
+ // (一次反馈——发送就是这条的收尾点)
1594
+ if (ev.key === 'Enter' && !ev.shiftKey && !ev.altKey && !ev.ctrlKey && !ev.metaKey) {
1595
+ /* 无条件 closePopup:即使浮层还没画出来(候选响应还在路上)也要把代数 +1,
1596
+ 否则回车之后到达的响应照样会把它画出来 —— 2026-10-21 真机 3081 把 /words 延迟 900ms 后复现到的正是这一格。 */
1597
+ closePopup()
1598
+ return
1599
+ }
1600
+ // 与 dsh-composer-history 共存:它用 Ctrl+R 开反向搜索。插件的候选层开着时先把层收掉,
1601
+ // 避免两个面板叠在一起(插件**不拦**这个键,交给它处理)——纸面对照后补。
1602
+ if (popup.open && (ev.ctrlKey || ev.metaKey) && (ev.key === 'r' || ev.key === 'R')) closePopup()
1603
+ // 片段占位符优先于补全接受:接受片段后 Tab 依次跳到下一个占位符(照 VS Code 的 snippet 行为)。
1604
+ // 非 Tab 的按键清空待跳表 —— 用户一动别的,就不该再跳。
1605
+ if (snippetStops && snippetStops.length) {
1606
+ if (ev.key === 'Tab' && !ev.shiftKey) {
1607
+ ev.preventDefault()
1608
+ var nowAt = caretPos(ed)
1609
+ var nxt = -1
1610
+ for (var si = 0; si < snippetStops.length; si++) {
1611
+ if (snippetStops[si] > nowAt) { nxt = snippetStops[si]; break }
1612
+ }
1613
+ if (nxt >= 0) placeCaretVerified(ed, nxt, textOf(ed))
1614
+ else snippetStops = null
1615
+ return
1616
+ }
1617
+ snippetStops = null
1618
+ }
1619
+ // 左右方向键 / Home / End 是「挪光标改字」,不是要补全:先把弹层收掉,再把键交回编辑器。
1620
+ // 只收这一次 —— 继续打字时 onInput 会重新拉候选,所以改完接着打仍然会提示。
1621
+ if (popup.open && (ev.key === 'ArrowLeft' || ev.key === 'ArrowRight' || ev.key === 'Home' || ev.key === 'End')) {
1622
+ closePopup()
1623
+ }
1624
+ // 弹层优先:上下选必须先于 keyAction —— keyAction 对箭头返回空,放在它后面就永远走不到
1625
+ // (的一次反馈)。
1626
+ if (popup.open && (ev.key === 'ArrowDown' || ev.key === 'ArrowUp')) {
1627
+ ev.preventDefault()
1628
+ popup.index = (popup.index + (ev.key === 'ArrowDown' ? 1 : -1) + popup.items.length) % popup.items.length
1629
+ // 必须 force:renderPopup 的签名只含 items 与语言水印、不含 index,
1630
+ // 非 force 时同一个 sig 会早退 →「按了键但高亮不动」(就是这个)。
1631
+ renderPopup(ed, true)
1632
+ showGhost(ed) // 行内预览跟着选中项走(VS Code 的 suggest.preview 口径)
1633
+ return
1634
+ }
1635
+ var act = keyAction(k)
1636
+ if (!act) return
1637
+ if (act === 'dismiss') { if (popup.open) { ev.preventDefault(); closePopup() } return }
1638
+ if (act === 'accept' && popup.open) { ev.preventDefault(); acceptCurrent(); return }
1639
+ if (act === 'complete') {
1640
+ ev.preventDefault()
1641
+ lastToken = tokenBefore(textBeforeCaret(ed))
1642
+ fetchCandidates(lastToken, ed)
1643
+ return
1644
+ }
1645
+ // (一次反馈):原来这里排除了 shift,
1646
+ // 于是 `{` `"` 这类要按 Shift 才打出来的开符、以及中文输入法 Shift+9 的「(」全都不配对。
1647
+ // 配对动作只对开符触发,Shift 不影响判断,放开它。
1648
+ if (act === 'pairopen' && ev.repeat) {
1649
+ // 按住符号键的自动重复:只插一个字符,不再成对(否则越按越多)
1650
+ ev.preventDefault()
1651
+ insertText(ed, ev.key)
1652
+ return
1653
+ }
1654
+ if (act === 'pairopen' && !ev.altKey && !ev.ctrlKey && !ev.metaKey) {
1655
+ // 反选优先:上一次框选范围还在、这次又是同一个引号 → 取消那次框选
1656
+ if (tryUnwrap(ed, ev.key)) { ev.preventDefault(); return }
1657
+ var before = textBeforeCaret(ed)
1658
+ var selTxt = selectedText(ed)
1659
+ // 决策走 decideType:全角换算在这一层完成,下面各分支只按 open/close 落地
1660
+ var d0 = decideType(ev.key, { before: before, after: caretChar(ed, +1), selection: selTxt, fwHalf: fwHalf() })
1661
+ var open = d0.open, close = d0.close
1662
+ fwKeyHandled = !!FW_TO_ASCII[ev.key]
1663
+ // 明明有选区却读不出文本(选区落在 chip / mention 这类非文本节点上):
1664
+ // 这时候任何插入都会把选区替掉,等于用引号吃掉选中内容 —— 宁可这一下什么都不做。
1665
+ if (!selTxt && k.__collapsedFalse) { ev.preventDefault(); return }
1666
+ // warn 模式:`"你好"` 这种已经框好的,再打同一个引号什么都不做(与 auto 的旧契约一致)
1667
+ if (quoteMode() !== 'auto' && isQuoteChar(open) && alreadyClosedBefore(before, open, close)) {
1668
+ ev.preventDefault(); return
1669
+ }
1670
+ // 空对里再打同一个引号 → 跳过(实测:快速连打时上一键的光标复位还没落地,
1671
+ // 光标可能停在对子后面,于是又补一对变成三个)。按**文本形状**判,不看光标:
1672
+ // 整段就是一个空对,或光标正好夹在空对中间,都算「已经在空对里」。
1673
+ if (close === open && isQuoteChar(open) && quoteMode() === 'auto') {
1674
+ // 仅 auto 模式:连打竞态下光标可能停在对子后面,此时跳过而不是再补一对。
1675
+ // warn 模式不自动补对,连按必须逐个字面插入()。
1676
+ var tv = textOf(ed), pv = caretPos(ed)
1677
+ var onlyPair = tv === open + close
1678
+ var straddle = pv > 0 && pv < tv.length && tv.charAt(pv - 1) === open && tv.charAt(pv) === close
1679
+ if (onlyPair || straddle) {
1680
+ ev.preventDefault()
1681
+ placeCaretVerified(ed, tv.length, tv) // 光标移到闭符之后
1682
+ return
1683
+ }
1684
+ }
1685
+ ev.preventDefault()
1686
+ if (selTxt) {
1687
+ // 拦住 Lexical:插件的监听是 capture 阶段,stopPropagation 之后它收不到这次按键,
1688
+ // 就不会"按自己的模型再插一遍"——实测 实测:不拦时 418ms 贴入 `“abc”`、
1689
+ // 423ms 它又插一个 `“`,于是撤销撤掉的是那个多余引号,而不是把原文还回来。
1690
+ try { ev.stopImmediatePropagation && ev.stopImmediatePropagation() } catch (e0) {}
1691
+ try { ev.stopPropagation() } catch (e0b) {}
1692
+ // 已有一个没闭合的开符在前(`"abc` 里选中 abc 再打 `"`):这是「补闭合」而不是「再框一层」,
1693
+ // 否则会变成 `""abc"`。对称引号才这么做;非对称的(“”)按原来的框选走。
1694
+ if (close === open && isUnbalancedBefore(before, open, close)) {
1695
+ var selEnd = caretOffset(ed) + selTxt.length
1696
+ if (isText(ed)) { ed.selectionStart = ed.selectionEnd = ed.selectionEnd } else setSelectionRange(ed, selEnd, selEnd)
1697
+ insertText(ed, open)
1698
+ } else wrapSelection(ed, open, selTxt, close)
1699
+ } // 环绕选区(2026-10-16 修:选区还原到内层)
1700
+ else if (caretChar(ed, +1) === close) {
1701
+ // 右边已经是这一对:跳过去,不要套一对(`“”` 中间再打 `“` 会变成一对套一对)
1702
+ var skipTo = caretPos(ed)
1703
+ if (skipTo >= 0) placeCaretVerified(ed, skipTo + 1, textOf(ed)) // 不插字,文本不变
1704
+ // 光标停在一个**已闭合**的闭符之后时不再吞掉这一键(旧实现 preventDefault 了什么都不做):
1705
+ // 反馈是「其他引号都闭合的情况下,新打的引号没有成对」。现在交给 shouldPair 决定,
1706
+ // 默认 auto 下就是再开一对;要「不堆一个」请切到标红模式或开严格配对。
1707
+ } else if (before.slice(-1) === open
1708
+ && isUnbalancedBefore(before, open, close)
1709
+ && textOf(ed).length > before.length) {
1710
+ // 光标紧贴一个未闭合的开符、后面还有正文(`"|你好`)。
1711
+ // **旧实现把闭符 setCaret 到全文末尾** —— 这就是
1712
+ // 「后引号出现在句子后面、不在我要的位置」。改成一律在光标处字面插入:
1713
+ // 用户在哪打就在哪落,闭合与否交给 lintQuotes 标红提示。
1714
+ var atHere = caretPos(ed)
1715
+ insertText(ed, open)
1716
+ if (atHere >= 0) { try { setSelectionRange(ed, atHere + 1, atHere + 1) } catch (e) {} }
1717
+ } else if (close === open && before.slice(-1) === open && quoteCount(before) % 2 === 1) {
1718
+ // 对称引号(" ' `)前面是**还没闭合**的开符:只插一个(这是补闭合,不是再开一层)。
1719
+ // 前面那对已经闭合时走下面 shouldPair,开一对新的。
1720
+ // VS Code 的引号规则;不补这条时连按引号会 2→4→6 成串(的一次反馈)。
1721
+ insertText(ed, open)
1722
+ } else if (isUnbalancedBefore(before, open, close)) {
1723
+ // 更前面还有一个没闭合的开符(`"你好` 末尾再打 `"`、`“文字` 末尾再打 `“`):
1724
+ // 这一下是在闭合它,不是再开一层 —— 只插一个字符。
1725
+ insertText(ed, open)
1726
+ } else if (shouldPair(before, caretChar(ed, +1), open)) {
1727
+ var basePair = textOf(ed), atPair = caretPos(ed)
1728
+ insertAtomic(ed, open + close) // 原子插入:避免 Lexical 异步重写选区导致的竞态
1729
+ // 先同步落一次光标:异步观察者赶不上下一次按键(实测快速连打会变 4 个引号)
1730
+ if (atPair >= 0) { try { setSelectionRange(ed, atPair + 1, atPair + 1) } catch (e) {} }
1731
+ // 开符落在 atPair 这一格,光标要落在它后面一格(= 两符之间)。
1732
+ // 等文本真的落地才写(见 placeCaretVerified),expect 用来认出「有没有人插手」
1733
+ placeCaretVerified(ed, atPair + 1,
1734
+ atPair < 0 ? undefined : basePair.slice(0, atPair) + open + close + basePair.slice(atPair),
1735
+ atPair < 0 ? null : afterPairCheck(ed, open, close, atPair))
1736
+ if (atPair >= 0) ensureCaretAt(ed, atPair + 1) // 编辑器若把光标挪到对子后面,这里按实际偏移纠回来
1737
+ } else {
1738
+ var atLiteral = caretPos(ed)
1739
+ insertText(ed, open)
1740
+ // 光标复位:字面插入后宿主可能把 caret 留在插字之前,导致下一次按键被
1741
+ // 「跳过闭符」判据吃掉(实测连按三下只得两个引号)。
1742
+ if (atLiteral >= 0) { try { setSelectionRange(ed, atLiteral + 1, atLiteral + 1) } catch (e) {} }
1743
+ }
1744
+ return
1745
+ }
1746
+ if (act === 'pairclose') {
1747
+ var expect = CLOSERS[ev.key]
1748
+ if (!selectedText(ed) && caretChar(ed, +1) === ev.key && PAIRS[expect] === ev.key) {
1749
+ // 后面就是应得的闭合符:跳过而不是再插一个(反引号对称,同判据成立)
1750
+ ev.preventDefault()
1751
+ var atClose = caretPos(ed)
1752
+ if (atClose >= 0) placeCaretVerified(ed, atClose + 1, textOf(ed)) // 不插字,文本不变
1753
+ return
1754
+ }
1755
+ // 引号且不该跳过时:必须字面插入,否则这一下被吞掉(实测连按三下只得两个引号)。
1756
+ // 括号不在此列 —— 让浏览器默认插入,别抢。
1757
+ if (isQuoteChar(ev.key)) {
1758
+ var bh = textBeforeCaret(ed)
1759
+ if (alreadyClosedBefore(bh, CLOSERS[ev.key], ev.key)) return // 已框好:不再堆一个
1760
+ ev.preventDefault()
1761
+ var atLit = caretPos(ed)
1762
+ insertText(ed, ev.key)
1763
+ if (atLit >= 0) { try { setSelectionRange(ed, atLit + 1, atLit + 1) } catch (e) {} }
1764
+ }
1765
+ return
1766
+ return
1767
+ }
1768
+ if (act === 'pairbackspace' && !selectedText(ed)) {
1769
+ var prev = caretChar(ed, -1), next = caretChar(ed, +1)
1770
+ if (PAIRS[prev] === next) { // 光标正好夹在空对里:一次删掉两侧(VSCode 同款)
1771
+ ev.preventDefault()
1772
+ // 两次删除各派发一次 input;全角/中文标点那半边有「开符后自动补闭符」,
1773
+ // 前删后框里只剩一个 `(`,它会立刻补回 `)`,结果就变成只删了一侧
1774
+ // (取证:`()`→`)`、`“”`→`”`,半角不受影响)。
1775
+ suppressInput += 2
1776
+ deleteForward(ed)
1777
+ deleteBackward(ed)
1778
+ }
1779
+ }
1780
+ }
1781
+ function deleteForward(ed) {
1782
+ try {
1783
+ if (isText(ed)) {
1784
+ var s = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set
1785
+ var i = ed.selectionStart
1786
+ s.call(ed, ed.value.slice(0, i) + ed.value.slice(i + 1))
1787
+ ed.dispatchEvent(new Event('input', { bubbles: true }))
1788
+ return
1789
+ }
1790
+ var off = caretOffset(ed)
1791
+ if (off < 0) return
1792
+ setSelectionRange(ed, off, off + 1) // 2026-10-16:从 sel.modify 换过来(Lexical 不认后者)
1793
+ document.execCommand('delete')
1794
+ } catch (e) {}
1795
+ }
1796
+ function deleteBackward(ed) {
1797
+ try {
1798
+ if (isText(ed)) {
1799
+ var s = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set
1800
+ var i = ed.selectionStart
1801
+ if (i <= 0) return
1802
+ s.call(ed, ed.value.slice(0, i - 1) + ed.value.slice(i))
1803
+ ed.selectionStart = ed.selectionEnd = i - 1
1804
+ ed.dispatchEvent(new Event('input', { bubbles: true }))
1805
+ return
1806
+ }
1807
+ var off = caretOffset(ed)
1808
+ if (off <= 0) return
1809
+ setSelectionRange(ed, off - 1, off) // 2026-10-16:同上
1810
+ document.execCommand('delete')
1811
+ } catch (e) {}
1812
+ }
1813
+
1814
+ // ── 模块:输入事件层 ────────────────────────────────
1815
+ // beforeinput/input 两条路:补闭符、收尾、引号未闭合标红、触发源定义(buildTriggerSources)。
1816
+ // 本文件是 lib/client.js 的片段,由 tools/build-client.mjs 按序拼回;不要直接改生成物 lib/client.js。
1817
+
1818
+ function textBeforeCaret(ed) {
1819
+ try {
1820
+ if (isText(ed)) return ed.value.slice(0, ed.selectionStart)
1821
+ var sel = window.getSelection(); if (!sel.rangeCount) return ''
1822
+ var r = sel.getRangeAt(0).cloneRange()
1823
+ var root = composerOf(ed) || document.body
1824
+ r.selectNodeContents(root); r.setEnd(sel.getRangeAt(0).endContainer, sel.getRangeAt(0).endOffset)
1825
+ return r.toString()
1826
+ } catch (e) { return '' }
1827
+ }
1828
+
1829
+ /* 引号未闭合提示:**必须在模块层**声明 —— 之前写在 apply() 里,而 scheduleQuoteLint
1830
+ * 在模块层引用它们,运行时抛 ReferenceError 被 try 吞掉,红线永远不出现(修)。 */
1831
+ var quoteLintTimer = null
1832
+
1833
+ /** 「未闭合引号」计数:**撇号不算**——`let's` / `don't` 里的 ' 与 ’ 被词字符夹着,
1834
+ * 那是英文所有格,不是引号(从没打引号也会冒出标红)。 */
1835
+ function unclosedQuoteCount(text) {
1836
+ var s = String(text || ''), n = 0
1837
+ for (var i = 0; i < s.length; i++) {
1838
+ var c = s.charAt(i)
1839
+ if (!isQuoteChar(c)) continue
1840
+ if (c === '\u0027' || c === '\u2019') {
1841
+ var pv = s.charAt(i - 1), nx = s.charAt(i + 1)
1842
+ if (/[A-Za-z0-9]/.test(pv) && /[A-Za-z0-9]/.test(nx)) continue
1843
+ }
1844
+ n++
1845
+ }
1846
+ return n
1847
+ }
1848
+ function lintQuotes(ed) {
1849
+ if (!ed) return
1850
+ var box = composerOf && composerOf(ed) || ed
1851
+ if (!box || !box.setAttribute) return
1852
+ var t = ''
1853
+ try { t = textOf(ed) || '' } catch (e) { t = '' }
1854
+ var bad = unclosedQuoteCount(t) % 2 === 1
1855
+ paintQuoteWarn(ed, bad ? unmatchedQuoteIndex(t) : -1) // 只画在那个没闭合的引号底下
1856
+ try {
1857
+ if (bad) {
1858
+ box.setAttribute('data-dce-quote-warn', '1')
1859
+ // 不再挂文字提示(反馈:不需要显示「引号/反引号未闭合」),只留底下那条波浪线。
1860
+ box.setAttribute('data-dce-quote-hint', '')
1861
+ } else {
1862
+ // 置 '0' 而不是删除:CSS 只认 =1,且假 DOM/宿主里 removeAttribute 不一定生效
1863
+ box.setAttribute('data-dce-quote-warn', '0')
1864
+ box.setAttribute('data-dce-quote-hint', '')
1865
+ if (box.removeAttribute) box.removeAttribute('title')
1866
+ }
1867
+ } catch (e) {}
1868
+ }
1869
+
1870
+ function scheduleQuoteLint(target) {
1871
+ if (quoteLintTimer) clearTimeout(quoteLintTimer)
1872
+ quoteLintTimer = setTimeout(function () { try { lintQuotes(target) } catch (e) {} }, 120)
1873
+ }
1874
+ /* 注释行里不提示:行首(允许缩进)是 // # <!-- -- 的那一行,补全没有意义。
1875
+ * 引号里的内容照旧提示 —— 那一侧是字符串,正是要补的地方。 */
1876
+ var COMMENT_LINE = /^\s*(\/\/|#|<!--|--)/
1877
+ function inCommentLine(before) {
1878
+ var s = String(before || '')
1879
+ var i = s.lastIndexOf('\n')
1880
+ return COMMENT_LINE.test(i < 0 ? s : s.slice(i + 1))
1881
+ }
1882
+ function onInput(ev) {
1883
+ try { scheduleQuoteLint(ev && ev.target) } catch (e0) {} // 引号未闭合标红(不阻断输入)
1884
+ // 三种触发:'/' 与 '@'(引用/模型)、打标识符两个字母起自动弹(VSCode 手感)
1885
+ var ed = composerOf(ev.target)
1886
+ if (!ed) return
1887
+ if (suppressInput > 0) { suppressInput--; return } // 这是接受候选时自己插的,别再弹
1888
+ dbg('input', { ed: ed, data: ev && ev.data, itype: ev && ev.inputType,
1889
+ composing: composing || (ev && ev.isComposing), before: textBeforeCaret(ed) })
1890
+ if (composing || (ev && ev.isComposing)) { if (popup.open) closePopup(); return } // IME 合成中不补全
1891
+ // 粘贴/拖入:先把残留的弹层与灰字收掉,也不为粘贴内容补全(否则灰字会停在旧位置)
1892
+ var itype = ev && ev.inputType ? String(ev.inputType) : ''
1893
+ if (itype.indexOf('Paste') >= 0 || itype.indexOf('Drop') >= 0) { closePopup(); return }
1894
+ lastSelText = ''
1895
+ // 全角/中文成对符走这条:IME 合成的字符不一定带对应 keydown,keydown 那条路抓不到。
1896
+ var b0 = textBeforeCaret(ed)
1897
+ var prev = b0.slice(-1)
1898
+ var want = PAIRS[prev]
1899
+ // `“文字` 末尾再打 `“` 时,上一行插入的是「闭合」而不是「开启」,
1900
+ // 这里若还去补 `”` 就会变成 `“文字“”`(一次反馈)。
1901
+ // 全角括号开关:打开时把输入法刚合成的 (/【 换成半角对子(只在写代码时开)。
1902
+ // FW_TO_ASCII 在 10-foundation 里(模块级):这里的 var 会遮蔽它,导致 beforeinput 那条路取不到
1903
+ var keyJustHandled = fwKeyHandled
1904
+ fwKeyHandled = false
1905
+ if (fwHalf() && FW_TO_ASCII[prev] && !keyJustHandled) {
1906
+ var asciiOpen = FW_TO_ASCII[prev]
1907
+ var asciiClose = PAIRS[asciiOpen] || ''
1908
+ suppressInput++
1909
+ if (replaceBeforeCaret(ed, 1, asciiOpen + asciiClose)) return
1910
+ suppressInput--
1911
+ }
1912
+ if (want && prev.charCodeAt(0) > 127 && caretChar(ed, +1) !== want
1913
+ && !isUnbalancedBefore(b0.slice(0, -1), prev, want)
1914
+ && shouldPair(b0.slice(0, -1), caretChar(ed, +1), prev)) {
1915
+ suppressInput++ // 自己插的闭符别再触发一轮
1916
+ var baseWant = textOf(ed), atWant = caretPos(ed)
1917
+ insertText(ed, want)
1918
+ // 闭符插在开符之后,光标回到两符之间
1919
+ placeCaretVerified(ed, b0.length,
1920
+ atWant < 0 ? undefined : baseWant.slice(0, atWant) + want + baseWant.slice(atWant))
1921
+ return
1922
+ }
1923
+ var before = textBeforeCaret(ed)
1924
+ lastComposer = ed
1925
+ // 注释行:整行不提示(引号里的内容仍在引号那条规则之外,照旧提示)
1926
+ if (inCommentLine(before)) { if (popup.open) closePopup(); return }
1927
+ var m = before.match(/(^|\s)([/@])([A-Za-z0-9_.\\/:,-]*)$/)
1928
+ if (m) {
1929
+ // '/' 是 DSH 原生命令入口(dsh-commands:command/run、command/done):
1930
+ // 插件不插手,免得两份菜单叠在一起、也免得给那条路加延迟(融合而不是抢)。
1931
+ if (m[2] === '/') { if (popup.open) closePopup(); return }
1932
+ lastToken = m[2] + m[3]; fetchCandidates(lastToken, ed); return
1933
+ }
1934
+ var w = before.match(/([A-Za-z_][A-Za-z0-9_]{1,}|[\u4e00-\u9fff]{1,})$/)
1935
+ if (w) {
1936
+ var tok = w[1]
1937
+ // 汉字没有空格分词:整句会被当成一个 token,取最后 4 个字当前缀(够长又不至于永不命中)
1938
+ if (/^[\u4e00-\u9fff]+$/.test(tok)) {
1939
+ // 中文至少要 2 个字才算「完整词义」,单字不提示()
1940
+ if (tok.length < 2) { if (popup.open) closePopup(); return }
1941
+ if (tok.length > 4) tok = tok.slice(-4)
1942
+ }
1943
+ lastToken = tok
1944
+ fetchCandidates(lastToken, ed)
1945
+ return
1946
+ }
1947
+ if (popup.open) closePopup()
1948
+ }
1949
+ /* 框选 + 打符号(原话「选中然后 shift 加符号框住」):
1950
+ * keydown 那条路对半角好使;**中文输入法/全角没有可用的 keydown**,所以再挂一条 beforeinput。
1951
+ * 实测取证(demo 3081 + Playwright)把两条老假设推翻了:
1952
+ * ① keydown 里读 DOM **不是**空的 —— 探针实测值 `text='\"你好' caret=3` 都对,影子状态不需要;
1953
+ * ② `preventDefault()` **拦不住 Lexical**:它照自己的模型再插一遍,于是「拦 + 自己插」出双份。
1954
+ * 现存唯一的真空窗是 `input` 事件:空框里首次插入 Lexical 根本不派发它(探针里只有
1955
+ * beforeinput,没有 input),所以「事后靠 onInput 补闭符」在那几场永远不触发。
1956
+ * 于是这条路改成:**判定在 beforeinput 做,落地等原生插完再收尾**(不拦默认、不自己插开符)。 */
1957
+ var pendingFix = null
1958
+ function textOf(ed) { return isText(ed) ? String(ed.value || '') : flatText(ed) }
1959
+ /** 删掉绝对偏移上的那一个字符(原生多插时用)。 */
1960
+ function deleteAt(ed, off) {
1961
+ try {
1962
+ if (isText(ed)) {
1963
+ var next = ed.value.slice(0, off) + ed.value.slice(off + 1)
1964
+ var desc = null
1965
+ try {
1966
+ desc = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')
1967
+ } catch (e0) { desc = null }
1968
+ if (desc && desc.set) desc.set.call(ed, next) // 受控组件只认原型 setter
1969
+ else ed.value = next // 没有描述符的环境直接赋值
1970
+ ed.selectionStart = ed.selectionEnd = off
1971
+ ed.dispatchEvent(new Event('input', { bubbles: true }))
1972
+ return
1973
+ }
1974
+ setSelectionRange(ed, off, off + 1)
1975
+ document.execCommand('delete')
1976
+ } catch (e) {}
1977
+ }
1978
+ function onBeforeInput(ev) {
1979
+ try {
1980
+ var ed = composerOf(ev.target)
1981
+ if (!ed) return
1982
+ flushCaretFix() // 同上:先让上一键挂起的光标复位落地,再读状态
1983
+ if (suppressInput > 0) return
1984
+ var data = ev && typeof ev.data === 'string' ? ev.data : ''
1985
+ dbg('beforeinput', { ed: ed, data: data, itype: ev && ev.inputType, before: textBeforeCaret(ed), composing: composing })
1986
+ if (data.length !== 1 || !PAIRS[data]) return
1987
+ var open = data, close = PAIRS[open]
1988
+ var selTxt = selectedText(ed)
1989
+ if (selTxt) {
1990
+ ev.preventDefault() // 文本域认这次取消;真 Lexical 不认(2026-10-20 探针:照样把选区替成那一个字符)
1991
+ // 再断掉传播:宿主那份监听在编辑器根节点上,不拦它就会自己把选区替成那一个字符,
1992
+ // 于是包裹变成「多一个开符」再由 repairWrapDup 删掉 —— 撤回里就多出「三个引号、两个引号」两拍。
1993
+ try { ev.stopImmediatePropagation && ev.stopImmediatePropagation() } catch (e1) {}
1994
+ try { ev.stopPropagation() } catch (e2) {}
1995
+ var bfSel = textBeforeCaret(ed)
1996
+ var selEnd = bfSel.length + selTxt.length
1997
+ if (close === open && isUnbalancedBefore(bfSel, open, close)) {
1998
+ // 前面已有一个没闭合的同一个引号:这一下是补闭合,不是再框一层
1999
+ setCaret(ed, selEnd)
2000
+ insertText(ed, open)
2001
+ return
2002
+ }
2003
+ if (isText(ed)) { wrapSelection(ed, open, selTxt, close); return }
2004
+ // contenteditable:Lexical 会把选区替成那一个开符,框完就多出一个
2005
+ // (实测值:选中 abc 打 “ → `““abc”`)。所以框完之后把多出来的那一个删掉。
2006
+ pendingFix = { ed: ed, open: open, close: close, off: bfSel.length - selTxt.length, selTxt: selTxt, action: 'surround' }
2007
+ wrapSelection(ed, open, selTxt, close)
2008
+ soon(flushBeforeFix)
2009
+ return
2010
+ }
2011
+ var before = textBeforeCaret(ed)
2012
+ var off = before.length
2013
+ var next = caretChar(ed, +1)
2014
+ // 决策与 keydown 同一条:decideType 给出 open/close,这里只负责落地
2015
+ var keyHandled = fwKeyHandled
2016
+ fwKeyHandled = false
2017
+ var d = decideType(open, { before: before, after: next, selection: '', fwHalf: fwHalf() && !keyHandled })
2018
+ if (d.action === 'insert') return // 让原生插一个,零干预
2019
+ pendingFix = { ed: ed, open: d.open, close: d.close, off: off, len: before.length, action: d.action }
2020
+ soon(flushBeforeFix)
2021
+ } catch (e) {}
2022
+ }
2023
+ /** 原生那一个字符落地之后收尾。**幂等**:实测上 `input` 有时来有时不来,
2024
+ * onInput 那条补闭符的路和这里会撞在一起 —— 谁先到都只补一次(落地后先读有没有闭符)。 */
2025
+ function flushBeforeFix() {
2026
+ var p = pendingFix
2027
+ if (!p) return
2028
+ pendingFix = null
2029
+ try {
2030
+ var ed = p.ed, now = textOf(ed)
2031
+ if (p.action === 'surround') {
2032
+ var ladder = [0, 16, 48, 110, 220], k = 0
2033
+ var tick = function () {
2034
+ try {
2035
+ var nw = textOf(ed)
2036
+ if (nw.slice(p.off, p.off + 2) === p.open + p.open) { suppressInput++; deleteAt(ed, p.off); nw = textOf(ed) }
2037
+ var afterClose = p.off + 1 + p.selTxt.length + 1
2038
+ if (nw.charAt(afterClose - 1) === p.close) { placeCaretVerified(ed, afterClose, nw); return }
2039
+ } catch (e) {}
2040
+ if (++k < ladder.length) { try { setTimeout(tick, ladder[k]) } catch (e) {} }
2041
+ }
2042
+ tick()
2043
+ return
2044
+ }
2045
+ if (p.action === 'convert') {
2046
+ var ladderC = [0, 16, 48, 110, 220, 360], kc = 0
2047
+ var pairTxt = p.open + p.close
2048
+ var tickC = function () {
2049
+ try {
2050
+ var cur = textOf(ed)
2051
+ if (FULL_TO_HALF_OPEN[cur.charAt(p.off)]) {
2052
+ backspaceOnce(ed)
2053
+ setCaret(ed, p.off)
2054
+ suppressInput++
2055
+ insertText(ed, pairTxt)
2056
+ placeCaretVerified(ed, p.off + 1, cur.slice(0, p.off) + pairTxt + cur.slice(p.off + 1))
2057
+ return
2058
+ }
2059
+ } catch (e) {}
2060
+ if (++kc < ladderC.length) { try { setTimeout(tickC, ladderC[kc]) } catch (e) {} }
2061
+ }
2062
+ tickC()
2063
+ return
2064
+ }
2065
+ if (p.action === 'skip') {
2066
+ if (now.length === p.len) { placeCaretVerified(ed, Math.min(p.off + 1, now.length), now); return }
2067
+ if (now.charAt(p.off) !== p.open) return
2068
+ suppressInput++
2069
+ deleteAt(ed, p.off)
2070
+ var afterDel = textOf(ed)
2071
+ placeCaretVerified(ed, Math.min(p.off + 1, afterDel.length), afterDel)
2072
+ return
2073
+ }
2074
+ if (now.charAt(p.off) !== p.open) return
2075
+ if (now.charAt(p.off + 1) === p.close) { placeCaretVerified(ed, p.off + 1, now); return }
2076
+ suppressInput++
2077
+ setCaret(ed, p.off + 1)
2078
+ insertText(ed, p.close)
2079
+ try { setSelectionRange(ed, p.off + 1, p.off + 1) } catch (e) {}
2080
+ placeCaretVerified(ed, p.off + 1, now.slice(0, p.off + 1) + p.close + now.slice(p.off + 1),
2081
+ afterPairCheck(ed, p.open, p.close, p.off))
2082
+ ensureCaretAt(ed, p.off + 1)
2083
+ } catch (e) {}
2084
+ }
2085
+
2086
+ function onCompositionStart(ev) {
2087
+ if (!composerOf(ev.target)) return
2088
+ composing = true
2089
+ caretEpoch++ // 输入法接管:作废我们自己挂起的光标复位,别跟它抢
2090
+ closePopup() // 连灰字一起收起来(hideGhost 在 closePopup 里)
2091
+ }
2092
+ function onCompositionEnd(ev) {
2093
+ if (!composerOf(ev.target)) return
2094
+ composing = false
2095
+ // 上屏后按新内容重新判一次(中文词补全走这条路)
2096
+ try { onInput({ target: ev.target }) } catch (e) {}
2097
+ }
2098
+
2099
+ function onBlurOut(ev) {
2100
+ var ed = composerOf(ev.relatedTarget)
2101
+ if (!ed && popup.open) closePopup()
2102
+ }
2103
+
2104
+ /* ── 宿主输入触发源(照被录用插件的样子做)─────────────────────────────
2105
+ * dsh-client-ui-commands / -skill / -reference 都是把内容注册进宿主的输入触发菜单,
2106
+ * 位置、上下选、插入与撤销都由宿主负责。插件只提供 candidates 与 onPick,
2107
+ * 不再自己画弹层、也不再跟宿主抢触发字符。 */
2108
+ /* 「严格配对」开关(普通情况不必那么严,需要时可开)。
2109
+ * 存在 localStorage,默认 false;由 '/' 菜单里插件那组的第一条切换。 */
2110
+ // 借宿主的命令表做两件事:① 让本组与「指令」组同一拍落地(宿主那组要等命令目录 RPC,
2111
+ // 原来立即 resolve,菜单先蹦三行再被顶下去);② 错拼时按它的表补一行。
2112
+ // 取不到(老宿主 / 没这个服务)就照旧立即返回,功能不缺。
2113
+ function hostCommands(ctx, session, req) {
2114
+ var cu = null
2115
+ try { cu = ctx && typeof ctx.get === 'function' ? ctx.get('commandUi') : null } catch (e) { cu = null }
2116
+ if (!cu || typeof cu.candidates !== 'function') return Promise.resolve(null)
2117
+ var full = { query: '', position: (req && req.position) || 'leading', signal: req && req.signal }
2118
+ try {
2119
+ return Promise.resolve(cu.candidates(session, req)).then(function (matched) {
2120
+ return Promise.resolve(cu.candidates(session, full)).then(function (all) {
2121
+ return { matched: matched || [], all: all || [] }
2122
+ })
2123
+ }).catch(function () { return null })
2124
+ } catch (e) { return Promise.resolve(null) }
2125
+ }
2126
+ function buildTriggerSources(ctx) {
2127
+ var fileSource = {
2128
+ trigger: '@',
2129
+ name: '便捷输入框', // 分组名:与宿主「文件与文件夹」并列,名字要唯一(重复注册会抛)
2130
+ order: 50,
2131
+ showGroupTitle: true,
2132
+ candidates: function (session, req) {
2133
+ var q = String((req && req.query) || '')
2134
+ return fetchItems(q).then(function (items) {
2135
+ return items.map(function (it) {
2136
+ var v = String(it.value || '')
2137
+ return {
2138
+ name: v,
2139
+ label: triggerBasename(v),
2140
+ description: String(it.detail || ''),
2141
+ hint: it.kind === 'file' ? '文件' : (it.kind === 'model' ? '模型' : ''),
2142
+ }
2143
+ })
2144
+ })
2145
+ },
2146
+ onPick: function (pick) { return { text: String(pick.candidate.name) } },
2147
+ }
2148
+ var slashSource = {
2149
+ trigger: '/',
2150
+ name: '便捷输入框', // 与宿主的「指令」组并列;同 trigger 下 name 重复会抛
2151
+ // 宿主按 order 升序排:内置「指令」= 1、「技能」= 2。写 20 会被垫到技能下面
2152
+ // (的一次反馈)。开关类要放最上面,一打开菜单就看得到。
2153
+ order: 0,
2154
+ showGroupTitle: true,
2155
+ candidates: function (session, req) {
2156
+ var q = String((req && req.query) || '').trim()
2157
+ // 三行也按 query 过滤:宿主那组因为 query 不命中而消失时,别只剩本插件这组还赖在菜单里
2158
+ var rows = settingsRows().filter(function (r) {
2159
+ if (!q) return true
2160
+ return looseScore(q, r.name) >= 0 || looseScore(q, r.label || '') >= 0
2161
+ })
2162
+ return hostCommands(ctx, session, req).then(function (host) {
2163
+ var leading = !req || req.position !== 'inline'
2164
+ return rows.concat(rescueRows(q, host, leading))
2165
+ })
2166
+ },
2167
+ onPick: function (pick) {
2168
+ var cand = (pick && pick.candidate) || {}
2169
+ // 错拼兜底行:补成真命令名再交给宿主,插件自己不解命令
2170
+ if (cand.rescue) return { text: '/' + String(cand.rescue) + ' ' }
2171
+ if (String(pick.candidate.name) === '引号模式') setQuoteMode(quoteMode() === 'auto' ? 'warn' : 'auto')
2172
+ if (String(pick.candidate.name) === '重置插件设置') resetSettings()
2173
+ if (String(pick.candidate.name) === '全角括号') setFwHalf(!fwHalf())
2174
+ if (String(pick.candidate.name) === '严格配对') setPairStrict(!pairStrict())
2175
+ var pickName = String(pick.candidate.name)
2176
+ if (pickName === '候选类型') cycleKindPreset()
2177
+ return { text: '' } // 清掉输入框里的 '/',不插入任何文字
2178
+ },
2179
+ }
2180
+ return [fileSource, slashSource]
2181
+ }
2182
+
2183
+ // ── 模块:装载与卸载 ────────────────────────────────
2184
+ // apply:单例守卫、输入触发源注册、七个事件监听与卸载。
2185
+ // 本文件是 lib/client.js 的片段,由 tools/build-client.mjs 按序拼回;不要直接改生成物 lib/client.js。
2186
+
2187
+ function apply(ctx) {
2188
+ // 先跑设置迁移:旧版默认值(引号=标红提示)不该一直粘住,否则「打引号不配对」会当成没修好
2189
+ migrateSettings()
2190
+ // 单例:同一页面被重复装载(HMR/重复注册)时,一次按键会被两套监听各处理一遍 → 引号/括号翻倍。
2191
+ if (typeof window !== 'undefined' && window.__DSH_COMPOSER_PRO_APPLIED__) {
2192
+ try { console.warn('[dsh-composer-ease] already applied, skip duplicate mount') } catch (e) {}
2193
+ return function () {}
2194
+ }
2195
+ if (typeof window !== 'undefined') window.__DSH_COMPOSER_PRO_APPLIED__ = true
2196
+ try {
2197
+ var strays = document.querySelectorAll('#dsh-cp-popup, #dsh-cp-ghost')
2198
+ for (var si = 0; si < strays.length; si++) strays[si].parentNode && strays[si].parentNode.removeChild(strays[si])
2199
+ } catch (e) {}
2200
+ /* 宿主输入触发源:照官方用法 ctx.inject([...], scope => …) —— 注入后的服务在 scope 上。
2201
+ * 前提:package.json 的 dsh.client.inject 与 exports.inject 都要声明 inputTriggers
2202
+ *(实测:只在其中一处声明时,apply 里取到的是 undefined)。 */
2203
+ var unwatchTriggers = []
2204
+ try { if (typeof window !== 'undefined') window.__DSE_TRIGGER_SOURCES__ = 0 } catch (e) {}
2205
+ var mountSources = function (svc) {
2206
+ if (!svc || typeof svc.registerSource !== 'function') return
2207
+ buildTriggerSources(ctx).forEach(function (src) {
2208
+ try {
2209
+ var off = svc.registerSource(src)
2210
+ unwatchTriggers.push(off)
2211
+ try { window.__DSE_TRIGGER_SOURCES__ = (window.__DSE_TRIGGER_SOURCES__ || 0) + 1 } catch (e) {}
2212
+ } catch (e) {
2213
+ try { console.warn('[dsh-composer-ease] registerSource 失败:', src.name, e && e.message) } catch (e2) {}
2214
+ }
2215
+ })
2216
+ }
2217
+ try {
2218
+ if (ctx && typeof ctx.inject === 'function') {
2219
+ ctx.inject(['inputTriggers'], function (scope) { mountSources(scope && scope.inputTriggers) })
2220
+ } else {
2221
+ mountSources((ctx && typeof ctx.get === 'function') ? ctx.get('inputTriggers') : null)
2222
+ }
2223
+ } catch (e) {
2224
+ try { console.warn('[dsh-composer-ease] 触发源注册异常:', e && e.message) } catch (e2) {}
2225
+ }
2226
+ var d = document
2227
+ d.addEventListener('keydown', onKeyDown, true) // capture:先于 Lexical 自己的键位
2228
+ d.addEventListener('beforeinput', onBeforeInput, true)
2229
+ // ── 引号未闭合的标红提示(VS Code 的 squiggle 口径,不阻断输入)──────────
2230
+ if (document.getElementById && !document.getElementById('dce-quote-style')) {
2231
+ var st = document.createElement('style')
2232
+ st.id = 'dce-quote-style'
2233
+ // 波浪线不再画在框底:那条线离文字约 27px,看着像给整个框描边,与「这个词有问题」无关。
2234
+ // 现在由 paintQuoteWarn() 用覆盖层逐行画在**文字框底边**上;这里只留行内提示文字。
2235
+ // 不再用 ::after 显示文字提示:标红只是一个位置信号,句子内容不该被这句话挤动。
2236
+ st.textContent = '[data-dce-quote-warn="1"]{background:none!important}'
2237
+ try { document.head && document.head.appendChild(st) } catch (e) {}
2238
+ }
2239
+ d.addEventListener('input', onInput, true)
2240
+ d.addEventListener('focusout', onBlurOut, true)
2241
+ d.addEventListener('compositionstart', onCompositionStart, true)
2242
+ d.addEventListener('compositionend', onCompositionEnd, true)
2243
+ return function () {
2244
+ d.removeEventListener('keydown', onKeyDown, true)
2245
+ d.removeEventListener('beforeinput', onBeforeInput, true)
2246
+ d.removeEventListener('input', onInput, true)
2247
+ d.removeEventListener('focusout', onBlurOut, true)
2248
+ d.removeEventListener('compositionstart', onCompositionStart, true)
2249
+ d.removeEventListener('compositionend', onCompositionEnd, true)
2250
+ for (var ui = 0; ui < unwatchTriggers.length; ui++) { try { unwatchTriggers[ui]() } catch (e) {} }
2251
+ if (typeof window !== 'undefined') { window.__DSH_COMPOSER_PRO_APPLIED__ = false; window.__DSE_TRIGGER_SOURCES__ = 0 }
2252
+ closePopup()
2253
+ }
2254
+ }
2255
+
2256
+ exports.apply = apply
2257
+ exports.inject = ['inputTriggers'] // 宿主输入触发服务:不声明则 scope 里拿不到(官方用法同)
2258
+ exports.__helpers__ = { decideType: decideType, buildTypeEdits: buildTypeEdits, autoCloseMode: autoCloseMode, setAutoCloseMode: setAutoCloseMode, shouldAutoCloseBefore: shouldAutoCloseBefore, AC_MODES: AC_MODES, AC_BEFORE_QUOTES: AC_BEFORE_QUOTES, AC_BEFORE_BRACKETS: AC_BEFORE_BRACKETS, pairStrict: pairStrict, setPairStrict: setPairStrict, migrateSettings: migrateSettings, resetSettings: resetSettings, fwHalf: fwHalf, setFwHalf: setFwHalf, repairWrapDup: repairWrapDup, unclosedQuoteCount: unclosedQuoteCount, expandSnippet: expandSnippet, isKindOn: isKindOn, toggleKind: toggleKind, kindPresetLabel: kindPresetLabel, cycleKindPreset: cycleKindPreset, quoteMode: quoteMode, setQuoteMode: setQuoteMode, lintQuotes: lintQuotes, quoteCount: quoteCount, buildTriggerSources: buildTriggerSources, settingsRows: settingsRows, looseScore: looseScore, typoScore: typoScore, rescueRows: rescueRows, inCommentLine: inCommentLine, PAIRS: PAIRS, PAIRED_CHAR: PAIRED_CHAR, CLOSERS: CLOSERS, shouldPair: shouldPair, tokenBefore: tokenBefore, keyAction: keyAction, docWords: docWords, langHint: langHint, inferLang: inferLang, inferLangVote: inferLangVote, watermarkFor: watermarkFor, conversationText: conversationText, contextWords: contextWords, contextReset: function () { ctxCache.at = 0; ctxCache.words = [] }, caretChar: caretChar, caretOffset: caretOffset, flatText: flatText }
2259
+
2260
+ return module.exports
2261
+ },
2262
+ })