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.
@@ -0,0 +1,396 @@
1
+ // ── 模块:表与文本工具 ────────────────────────────────
2
+ // 成对符号表(PAIRS/CLOSERS/家族/奇偶判据)、shouldPair、以及 contenteditable 的取文/摆光标/插字原语。
3
+ // 本文件是 lib/client.js 的片段,由 tools/build-client.mjs 按序拼回;不要直接改生成物 lib/client.js。
4
+
5
+ var ROUTE_WORDS = '/dsh-composer-ease/words'
6
+ // 加全角/中文成对符:实测「输入( 只出(」——中文输入法下原来完全没有配对。
7
+ // 全角走 input 事件那条补配对(IME 合成的字符不一定有对应的 keydown)。
8
+ var PAIRS = { '(': ')', '[': ']', '{': '}', '`': '`', '"': '"', "'": "'",
9
+ '(': ')', '【': '】', '「': '」', '『': '』', '《': '》', '〈': '〉', '“': '”', '‘': '’' }
10
+ var CLOSERS = { ')': '(', ']': '[', '}': '{', '`': '`', '"': '"', "'": "'",
11
+ ')': '(', '】': '【', '」': '「', '』': '『', '》': '《', '〉': '〈', '”': '“', '’': '‘' }
12
+
13
+ // ── 纯逻辑(selftest 直接喂)───────────────────────────────────────────
14
+ /** **成对家族**:所有成对符号的开符与闭符(半角 + 中文标点)。
15
+ * 三条反馈同一类:①有前引号再打引号出两个 ②中文模式打引号变多个
16
+ * ③括号也一样(`(` 后面再打 `(` 会变 `(()`)——所以守卫要从「引号」扩到整个家族:
17
+ * 光标前一个字符是家族里的任意成员时,只插一个字符、不补对。 */
18
+ var PAIRED_CHAR = /[()[\]{}()【】「」『』《》〈〉“”‘’"'`]$/
19
+
20
+ /** 光标前这段文字里,`open` 是否处在一个**没闭合**的状态。
21
+ * 一次反馈就是这个漏的:
22
+ * 原来只看前一个字符(`PAIRED_CHAR`),当前面是「好」这种正文时看不到更早那个开符,
23
+ * 于是又补一对,得到 `"你好""`。对称引号按奇偶判,非对称按开符多于闭符判。 */
24
+ /* 引号家族(根因):全角 “ 与半角 " 在判定里必须算同一个"引号位",
25
+ * 否则中文输入法下混着打就永远被判成"没有开符",于是一路补对、各处冒出多个引号。
26
+ * 规则只有一条:**引号永远成对,家族内合计字符数为奇 = 还有未闭合的引号**。
27
+ * 括号不进家族:`(` 与 `(` 语义不同,混用不算一对。 */
28
+ var QUOTE_FAMILY = '"\u201c\u201d\u201e\u201f\uff02\u0027\u2018\u2019\u201a\u201b\uff07'
29
+ function isQuoteChar(ch) { return !!ch && QUOTE_FAMILY.indexOf(ch) >= 0 }
30
+ function quoteCount(text) {
31
+ var q = String(text || ''), n = 0
32
+ for (var i = 0; i < q.length; i++) if (QUOTE_FAMILY.indexOf(q[i]) >= 0) n++
33
+ return n
34
+ }
35
+
36
+ /** 没闭合的那个引号在全文里的下标(撇号不算,与 unclosedQuoteCount 同口径)。
37
+ * 标红要画在**这个字符**底下,而不是整行:一行里只有一个引号真的有问题。 */
38
+ function unmatchedQuoteIndex(text) {
39
+ var s = String(text || ''), n = 0, last = -1
40
+ for (var i = 0; i < s.length; i++) {
41
+ var c = s.charAt(i)
42
+ if (!isQuoteChar(c)) continue
43
+ if (c === '\u0027' || c === '\u2019') {
44
+ var pv = s.charAt(i - 1), nx = s.charAt(i + 1)
45
+ if (/[A-Za-z0-9]/.test(pv) && /[A-Za-z0-9]/.test(nx)) continue
46
+ }
47
+ n++
48
+ last = i
49
+ }
50
+ return n % 2 === 1 ? last : -1
51
+ }
52
+
53
+ function isUnbalancedBefore(text, open, close) {
54
+ var s = String(text || '')
55
+ // 引号按家族奇偶判:`“你好` 与 `"你好` 同为未闭合,混着打也算一对
56
+ if (isQuoteChar(open) || isQuoteChar(close)) return quoteCount(s) % 2 === 1
57
+ var o = 0, c = 0
58
+ for (var i = 0; i < s.length; i++) {
59
+ if (s[i] === open) o++
60
+ else if (close !== open && s[i] === close) c++
61
+ }
62
+ return close === open ? (o % 2 === 1) : (o > c)
63
+ }
64
+ /** 光标前那一段的最后是不是「一个**非空**成对符号的闭符」——即这块内容已经被框好了。
65
+ * 用来挡「框完再打一次闭符,于是多堆一个」:`"你好"` 再打 `"` 不该变成 `"你好""`。
66
+ * **空对(`""`、`()`)不算**:连打引号/括号是「写 ``` 围栏」那类正常用法,见行为矩阵里的连按用例。 */
67
+ function alreadyClosedBefore(before, open, close) {
68
+ var s = String(before || '')
69
+ if (s.length < 3) return false // 至少要「开符 + 一个字符 + 闭符」
70
+ // 只认「同字符」的已闭合(`"abc"` 后再打 " 才跳过)。
71
+ // 混宽一律不吞 —— 那是的一次反馈。
72
+ if (s.charAt(s.length - 1) !== close) return false
73
+ if (s.charAt(s.length - 2) === open) return false // 空对:放行(连按引号仍要能打出来)
74
+ return isUnbalancedBefore(s.slice(0, -1), open, close)
75
+ }
76
+ /** 该不该补闭符:上一个字符是成对家族成员→不配;右边紧跟字母/数字/汉字→不配;其余补。 */
77
+ /** 唯一决策入口:纯函数,返回 { action: wrap|skip|convert|pair|insert, open, close }。 */
78
+ function decideType(ch, ctx) {
79
+ var c = ctx || {}
80
+ var before = String(c.before || '')
81
+ var after = String(c.after || '')
82
+ var open = ch, close = PAIRS[ch] || ''
83
+ if (c.selection) return { action: 'wrap', open: open, close: close }
84
+ if (c.fwHalf && FW_TO_ASCII[ch]) { open = FW_TO_ASCII[ch]; close = PAIRS[open] || ''; return { action: 'convert', open: open, close: close } }
85
+ if (after === close && close) return { action: 'skip', open: open, close: close }
86
+ if (close && alreadyClosedBefore(before, open, close)) return { action: 'insert', open: open, close: close }
87
+ if (close === open && before.slice(-1) === open) return { action: 'insert', open: open, close: close }
88
+ if (close && isUnbalancedBefore(before, open, close)) return { action: 'insert', open: open, close: close }
89
+ if (shouldPair(before, after, ch)) return { action: 'pair', open: open, close: close }
90
+ return { action: 'insert', open: open, close: close }
91
+ }
92
+
93
+ // VS Code 打字拦截层(模式 + before 集合 + 编辑列表),常量取自其产物
94
+ var AC_MODES = ['always', 'languageDefined', 'beforeWhitespace', 'never']
95
+ var AC_BEFORE_QUOTES = ';:.,=}])> \t'
96
+ var AC_BEFORE_BRACKETS = '\'"`;:.,=}])> \t'
97
+ var AC_BEFORE_WS = ' \t'
98
+
99
+ /** 设置 → 模式(认不得的值按 languageDefined 处理,与 VS Code 的默认值一致)。 */
100
+ function autoCloseMode(kind) {
101
+ var v = ''
102
+ try { v = String(window.localStorage.getItem('dsh-composer-ease:autoClose' + (kind === 'quote' ? 'Quotes' : 'Brackets')) || '') } catch (e) {}
103
+ return AC_MODES.indexOf(v) >= 0 ? v : 'languageDefined'
104
+ }
105
+ function setAutoCloseMode(kind, mode) {
106
+ try { window.localStorage.setItem('dsh-composer-ease:autoClose' + (kind === 'quote' ? 'Quotes' : 'Brackets'), AC_MODES.indexOf(mode) >= 0 ? mode : 'languageDefined') } catch (e) {}
107
+ }
108
+
109
+ /** VS Code 的 shouldAutoCloseBefore:只有前一个字符落在集合里才补对。kind 决定用哪张集合。 */
110
+ function shouldAutoCloseBefore(prev, mode, kind) {
111
+ var m = AC_MODES.indexOf(mode) >= 0 ? mode : 'languageDefined'
112
+ if (m === 'always') return true
113
+ if (m === 'never') return false
114
+ if (m === 'beforeWhitespace') return AC_BEFORE_WS.indexOf(prev) >= 0
115
+ var set = kind === 'quote' ? AC_BEFORE_QUOTES : AC_BEFORE_BRACKETS
116
+ // 空上下文、中文、字母数字后允许成对
117
+ if (!prev) return true
118
+ if (/[\u4e00-\u9fff]/.test(prev)) return true
119
+ if (/[A-Za-z0-9_]/.test(prev)) return true
120
+ return set.indexOf(prev) >= 0
121
+ }
122
+
123
+ /** 打一个字符要做的编辑(VS Code 的 typeWithInterceptors 子集):
124
+ * 返回 [{ range: [from, to], text }],按 from 升序;空数组=不干预,交原生插入。 */
125
+ function buildTypeEdits(ch, ctx) {
126
+ var c = ctx || {}
127
+ var before = String(c.before || ''), after = String(c.after || '')
128
+ var sel = c.selection || ''
129
+ var off = before.length
130
+ var close = PAIRS[ch] || ''
131
+ var kind = isQuoteChar(ch) ? 'quote' : 'bracket'
132
+ var mode = autoCloseMode(kind)
133
+ var edits = []
134
+ // 有选区:框住(surround)
135
+ if (sel) {
136
+ return [{ range: [off, off + sel.length], text: ch + sel + close, caret: off + 1, select: [off + 1, off + 1 + sel.length] }]
137
+ }
138
+ // 全角括号开关:换算成半角对子
139
+ if (c.fwHalf && FW_TO_ASCII[ch]) { ch = FW_TO_ASCII[ch]; close = PAIRS[ch] || '' }
140
+ // 右边已经是同一个闭符:跳过去(overtype)
141
+ if (close && after === close) return [{ range: [off + 1, off + 1], text: '', caret: off + 1 }]
142
+ if (!close) return edits
143
+ // 已闭合 / 未闭合:只插一个
144
+ if (alreadyClosedBefore(before, ch, close)) return edits
145
+ if (close === ch && before.slice(-1) === ch) return edits
146
+ if (isUnbalancedBefore(before, ch, close)) return edits
147
+ if (!shouldAutoCloseBefore(before.slice(-1), mode, kind)) return edits
148
+ if (/[A-Za-z0-9_\u4e00-\u9fff]/.test(String(after || ''))) return edits // 右边紧跟文字时不补(VS Code 的 beforeWhitespace 精神)
149
+ return [{ range: [off, off], text: ch + close, caret: off + 1 }]
150
+ }
151
+
152
+ function shouldPair(before, after, ch) {
153
+ // 引号(恢复「打一个引号得一对」):
154
+ // 严格配对开 → 老行为,一律补对;
155
+ // 严格配对关(默认)→ 也补对,但按 VS Code `editor.autoClosingQuotes` 的
156
+ // `beforeWhitespace` 口径收窄:**右边紧跟文字时不补**(保护「在一段已有文字前打前引号」,
157
+ // 实测发现的多一个引号就是这条),左边已是成对符号时也不补。
158
+ // 历史:曾一律只插一个,原因是配对后光标靠 DOM Range 复位,
159
+ // 快速连打会多冒一个引号;现在走的是括号那条 `insertAtomic + placeCaretVerified`
160
+ // 的复核链(文本护栏 + 跨帧校验),所以放回来。
161
+ if (isQuoteChar(ch)) {
162
+ // warn 模式(默认):不自动补闭引号 —— 闭符落到句子末尾、以及"打完一个引号多一个"的
163
+ // 两类投诉都源于自动补对;这里改为只插当前字符,由 lintQuotes 标红提示。
164
+ if (quoteMode() !== 'auto') return false
165
+ if (pairStrict()) return true
166
+ if (/[A-Za-z0-9_\u4e00-\u9fff]/.test(String(after || ''))) return false
167
+ // 引号是自对称的,前一个引号到底是开符还是闭符只能看**奇偶**:奇数 = 还悬着一个开符,
168
+ // 这一下不该再补对;偶数 = 前面那对已经闭合,新打的引号应当开一对新的(反馈:其他引号都闭合
169
+ // 的情况下,新打引号不出对)。
170
+ var lastCh = String(before || '').slice(-1)
171
+ if (isQuoteChar(lastCh) && quoteCount(String(before || '')) % 2 === 1) return false
172
+ if (!isQuoteChar(lastCh) && PAIRED_CHAR.test(lastCh)) return false
173
+ return true
174
+ }
175
+ if (/[A-Za-z0-9_\u4e00-\u9fff]/.test(String(after || ''))) return false
176
+ var last = before.slice(-1)
177
+ if (PAIRED_CHAR.test(last)) return false
178
+ // 与 VS Code 的 `editor.autoClosingBrackets: languageDefined` 对齐:词/数字后面照样补对
179
+ // (`abc` 后打 `(` 得 `abc()`)。旧规则在标识符后面不补,用户看到的就是「括号不配对」。
180
+ return true
181
+ }
182
+ /** 取 caret 前的当前 token(补全用):文件/标识符字符集,遇空白或换行截断。 */
183
+ /** 展开片段占位符:`${n:默认值}` → 默认值(或空),同时算出每个落点的**偏移**。
184
+ * 返回 { text, stops };stops 按占位符编号升序(与出现顺序无关),供 Tab 依次跳。 */
185
+ function expandSnippet(v) {
186
+ var src = String(v || '')
187
+ var re = /\$\{(\d+)(?::([^}]*))?\}/g
188
+ var out = '', stops = [], m, last = 0
189
+ while ((m = re.exec(src))) {
190
+ out += src.slice(last, m.index)
191
+ out += m[2] || ''
192
+ stops.push({ n: Number(m[1]), at: out.length })
193
+ last = re.lastIndex
194
+ }
195
+ out += src.slice(last)
196
+ stops.sort(function (a, b) { return a.n - b.n })
197
+ return { text: out, stops: stops.map(function (s) { return s.at }) }
198
+ }
199
+
200
+ function tokenBefore(text) {
201
+ var m = String(text || '').match(/([A-Za-z0-9_./@:,-]*)$/)
202
+ return m ? m[1] : ''
203
+ }
204
+ /** 键位决策表:返回动作名,selftest 与真实监听共用判据。 */
205
+ function keyAction(k) {
206
+ if (k.ctrl && k.code === 'Space') return 'complete'
207
+ if (k.key === 'Escape') return 'dismiss'
208
+ // (实测发现的):聊天框里**回车必须发送**,不能被补全弹层吃掉。
209
+ // 原来 Enter 也算接受,弹层开着时连按回车就把候选一遍遍插进正文(打出
210
+ // "i临时目录/attrib-index.dif临时目录/attrib-index.dif…" 那种滚雪球)。接受只留 Tab。
211
+ if (k.__popupOpen && k.key === 'Tab') return 'accept'
212
+ if (k.key === 'Backspace' && !k.__collapsedFalse) return 'pairbackspace'
213
+ // 对称引号(" ' `)右边已经是同一个字符:走「跳过」而不是再补一对(否则就是多出来的那一对)
214
+ if (k.__nextIsCloser && Object.prototype.hasOwnProperty.call(CLOSERS, k.key)) return 'pairclose'
215
+ if (Object.prototype.hasOwnProperty.call(PAIRS, k.key)) return 'pairopen'
216
+ if (Object.prototype.hasOwnProperty.call(CLOSERS, k.key)) return 'pairclose'
217
+ return null
218
+ }
219
+
220
+ // ── DOM 层 ──────────────────────────────────────────────────────────────
221
+ function composerOf(el) {
222
+ if (!el || el === document.body) return null
223
+ var c = el.closest ? el.closest('[data-composer-input], textarea, [contenteditable="true"], [contenteditable=""]') : null
224
+ return c
225
+ }
226
+ function isText(ed) {
227
+ return ed && (ed.tagName === 'TEXTAREA' || ed.tagName === 'INPUT')
228
+ }
229
+ /* 插件的 keydown 监听在**捕获阶段**,跑在 Lexical 自己的键位处理之前;插入后立刻改光标/选区
230
+ * 会被 Lexical 随后的渲染覆盖(实测扫出来的)。contenteditable 这条路一律往后推一拍再改选区。 */
231
+ function soon(fn) { try { setTimeout(fn, 0) } catch (e) { fn() } }
232
+
233
+ // 全角 → 半角映射(模块级:beforeinput 与 input 两条路都要用,放在某个处理器里会 ReferenceError)
234
+ var FW_TO_ASCII = { '(': '(', '【': '[' }
235
+ var FULL_TO_HALF_OPEN = { '(': 1, '【': 1 } // 需要换算的全角开符 // 只换括号:引号族保持输入法给的字符
236
+ // keydown 已经自己换过的那一下,beforeinput 不要再换一遍(否则 ( 变成 (() )
237
+ var fwKeyHandled = false
238
+
239
+ /** 删光标前一个字符:Lexical 只认按键路径的删除。 */
240
+ function backspaceOnce(ed) {
241
+ try {
242
+ window.__dceSyntheticBackspace = true
243
+ var ev = new KeyboardEvent('keydown', { key: 'Backspace', code: 'Backspace', keyCode: 8, which: 8, bubbles: true, cancelable: true })
244
+ ed.dispatchEvent(ev)
245
+ window.__dceSyntheticBackspace = false
246
+ return true
247
+ } catch (e) { window.__dceSyntheticBackspace = false; return false }
248
+ }
249
+
250
+ function insertText(ed, text) {
251
+ if (isText(ed)) {
252
+ var s = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set
253
+ var start = ed.selectionStart, end = ed.selectionEnd
254
+ s.call(ed, ed.value.slice(0, start) + text + ed.value.slice(end))
255
+ ed.selectionStart = ed.selectionEnd = start + text.length
256
+ ed.dispatchEvent(new Event('input', { bubbles: true }))
257
+ } else {
258
+ ed.focus()
259
+ document.execCommand('insertText', false, text)
260
+ }
261
+ }
262
+ /** 插文本的**通用入口**:多行走合成 paste(实测:片段是多行的,
263
+ * `execCommand('insertText')` 在真 Lexical 上会被吃掉 —— 连点选片段都插不进去)。
264
+ * 宿主接下 paste(返回 false = preventDefault 过)就不再兜底;否则退回 execCommand。 */
265
+ function insertRich(ed, text) {
266
+ var s = String(text == null ? '' : text)
267
+ if (isText(ed) || s.indexOf('\n') < 0) return insertText(ed, s)
268
+ try {
269
+ var dt = new DataTransfer()
270
+ dt.setData('text/plain', s)
271
+ var evP = new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })
272
+ if (ed.dispatchEvent(evP) === false) return
273
+ } catch (e) {}
274
+ insertText(ed, s)
275
+ }
276
+
277
+ function selectedText(ed) {
278
+ if (isText(ed)) return ed.value.slice(ed.selectionStart, ed.selectionEnd)
279
+ var sel = window.getSelection()
280
+ return sel && !sel.isCollapsed ? sel.toString() : ''
281
+ }
282
+
283
+ /* 实测扫出来的坑:Lexical 不认 `sel.modify(...)` —— 在真 composer 上
284
+ * 配对后的光标、跳闭合、删空对、环绕选区还原、Tab 替换 token 全都没生效(值对了、位置全错)。
285
+ * 改成把 composer 文本摊平成一个字符偏移,用显式 Range 定位/选区。文本节点之外的东西
286
+ * (br、mention 之类)按 0 长处理:真 composer 里就是纯文本,够用。 */
287
+ function flatText(ed) {
288
+ try {
289
+ var w = document.createTreeWalker(ed, 4 /* SHOW_TEXT */, null)
290
+ var out = '', node
291
+ while ((node = w.nextNode())) out += node.nodeValue || ''
292
+ return out
293
+ } catch (e) { return '' }
294
+ }
295
+ /** 数一个文档片段里文本节点的字符数(与 flatText 同口径:只数文本节点,不含块边界)。 */
296
+ function textLenOf(frag) {
297
+ try {
298
+ var w = document.createTreeWalker(frag, 4 /* SHOW_TEXT */, null)
299
+ var n = 0, node
300
+ while ((node = w.nextNode())) n += (node.nodeValue || '').length
301
+ return n
302
+ } catch (e) { return 0 }
303
+ }
304
+ function caretOffset(ed) {
305
+ try {
306
+ var sel = window.getSelection()
307
+ if (!sel || !sel.rangeCount) return -1
308
+ var r = sel.getRangeAt(0)
309
+ var pre = r.cloneRange()
310
+ pre.selectNodeContents(ed)
311
+ pre.setEnd(r.endContainer, r.endOffset)
312
+ return pre.toString().length
313
+ } catch (e) { return -1 }
314
+ }
315
+ /** 摆光标:文本域直接写 selectionStart,contenteditable 才需要 Range(setSelectionRange 只认 Selection)。 */
316
+ function setCaret(ed, off) {
317
+ if (off < 0) return
318
+ if (isText(ed)) { try { ed.selectionStart = ed.selectionEnd = off } catch (e) {} ; return }
319
+ setSelectionRange(ed, off, off)
320
+ }
321
+ /** 当前光标偏移,文本域与 contenteditable 同一个口径(caretOffset 只认 Selection)。 */
322
+ function caretPos(ed) {
323
+ if (isText(ed)) { try { return ed.selectionStart } catch (e) { return -1 } }
324
+ return caretOffset(ed)
325
+ }
326
+ /** 把选区设成 composer 内的 [start, end) 字符区间;失败返回 false。 */
327
+ /** 把光标前 count 个字符替换成 text(text 两字符时,光标落在两字符之间)。
328
+ * 输入法把 ( 合成 ( 之后要换成 (),text-input 与 contenteditable 两条路都得能走。 */
329
+ function replaceBeforeCaret(ed, count, text) {
330
+ if (isText(ed)) {
331
+ var pos = ed.selectionStart
332
+ if (pos < count) return false
333
+ var set = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set
334
+ set.call(ed, ed.value.slice(0, pos - count) + text + ed.value.slice(pos))
335
+ ed.selectionStart = ed.selectionEnd = pos - count + Math.max(1, text.length - 1)
336
+ ed.dispatchEvent(new Event('input', { bubbles: true }))
337
+ return true
338
+ }
339
+ var at = caretPos(ed)
340
+ var before = textOf(ed)
341
+ if (at < count || !setSelectionRange(ed, at - count, at)) return false
342
+ insertText(ed, text)
343
+ // 宿主可能吃掉这次 insertText(实测 Lexical 对 2 字符串会静默不落字):
344
+ // 按整段文本复核,没落地就交回给原来的配对路径,别让开关变成「点了没反应」。
345
+ var want = before.slice(0, at - count) + text + before.slice(at)
346
+ if (textOf(ed) !== want) {
347
+ if (setSelectionRange(ed, at, at)) { /* 复原光标,让调用方继续走老路 */ }
348
+ return false
349
+ }
350
+ placeCaretVerified(ed, at - count + Math.max(1, text.length - 1), want)
351
+ return true
352
+ }
353
+
354
+ function setSelectionRange(ed, start, end) {
355
+ try {
356
+ var sel = window.getSelection()
357
+ if (!sel) return false
358
+ var walker = document.createTreeWalker(ed, 4, null)
359
+ var acc = 0, node, startNode = null, startOff = 0, endNode = null, endOff = 0
360
+ while ((node = walker.nextNode())) {
361
+ var len = (node.nodeValue || '').length
362
+ if (startNode === null && acc + len >= start) { startNode = node; startOff = Math.max(0, start - acc) }
363
+ if (acc + len >= end) { endNode = node; endOff = Math.max(0, end - acc); break }
364
+ acc += len
365
+ }
366
+ var r = document.createRange()
367
+ if (startNode && endNode) { r.setStart(startNode, startOff); r.setEnd(endNode, endOff) }
368
+ else { r.selectNodeContents(ed); r.collapse(false) }
369
+ sel.removeAllRanges(); sel.addRange(r)
370
+ return true
371
+ } catch (e) { return false }
372
+ }
373
+
374
+ function caretChar(ed, dir) {
375
+ try {
376
+ if (isText(ed)) {
377
+ var i = ed.selectionStart
378
+ return dir > 0 ? ed.value[i] || '' : ed.value[i - 1] || ''
379
+ }
380
+ var off = caretOffset(ed)
381
+ if (off < 0) return ''
382
+ var t = flatText(ed)
383
+ return dir > 0 ? (t[off] || '') : (t[off - 1] || '')
384
+ } catch (e) { return '' }
385
+ }
386
+ function selectBack(ed, n) { // 把 caret 前 n 个字符变成选区(补全替换 token 用)
387
+ try {
388
+ if (isText(ed)) { ed.selectionStart = Math.max(0, ed.selectionStart - n); return }
389
+ var off = caretOffset(ed)
390
+ if (off >= 0) setSelectionRange(ed, Math.max(0, off - n), off)
391
+ } catch (e) {}
392
+ }
393
+ /** 环绕选区:把选中内容用一对符号框起来,光标落在闭符之后(框完继续打字接在闭符后面)。
394
+ * 插入只能走 **paste 通道**(合成 ClipboardEvent)—— `insertText` 拼好的多行文本会被 Lexical 吃掉,
395
+ * 连续两次 execCommand 也会被它的重渲染清成最后一个字符。没被接管时才退回 execCommand。 */
396
+ /* 上一次框选的范围:用于「再打一次同一个引号 = 取消这次框选」()。 */
@@ -0,0 +1,177 @@
1
+ // ── 模块:配对与框选 ────────────────────────────────
2
+ // wrapSelection / repairWrapDup / tryUnwrap / surroundSelection:把选区框起来、以及框完的反选与清理。
3
+ // 本文件是 lib/client.js 的片段,由 tools/build-client.mjs 按序拼回;不要直接改生成物 lib/client.js。
4
+
5
+ var lastWrap = null
6
+
7
+ /* 框选只是"一个动作":期间不许弹补全。
8
+ * 实测原因:Lexical 不认 preventDefault,会先把选区替成那一个字符,插件随后用 paste 补全;
9
+ * 这中间的 input 事件足以让自动补全弹出来 —— 也就是说的"暴露第一个字然后进行提醒"。 */
10
+ function wrapSelection(ed, open, selTxt, close) {
11
+ var from = textBeforeCaret(ed).length
12
+ var baseLen = textOf(ed).length
13
+ var prevSuppress = suppressInput
14
+ suppressInput = prevSuppress + 1
15
+ closePopup()
16
+ try {
17
+ surroundSelection(ed, open, selTxt, close)
18
+ } finally {
19
+ suppressInput = prevSuppress // 只压住自己这一次插入,不泄漏给下一键
20
+ var start = from + open.length
21
+ lastWrap = { ed: ed, open: open, close: close, start: start,
22
+ end: start + String(selTxt || '').length, inner: String(selTxt || '') }
23
+ // 落地后按真文本复核(实测:全角 “ 包裹会多出一个开符 ——
24
+ // Lexical 不认 preventDefault,粘贴之外它又按自己的键插了一遍;
25
+ // 于是 Ctrl+Z 撤掉的是那个多余引号,而不是把原文还回来)。
26
+ // 多拍复核:Lexical 那次"按自己的键再插一遍"落在插件第一次复核之后(实测),
27
+ // 所以按 0/16/48/120/240ms 各看一次,命中一次就收(修复是幂等的)。
28
+ var wantLen = baseLen + open.length + close.length
29
+ // 多行/带空格的包裹走 execCommand 兜底时会被吃掉一部分(实测:围栏里选中整段再框,
30
+ // 落地的是「开符 + 内容」,闭符没了)。所以落定后复核一次,缺哪一侧就补哪一侧。
31
+ ;[0, 16, 48, 120, 240].forEach(function (ms) {
32
+ setTimeout(function () { repairWrapDup(ed, open, String(selTxt || ''), close, wantLen) }, ms)
33
+ })
34
+ }
35
+ }
36
+
37
+ /* 「反选」:框完再打同一个引号 → 去掉两侧、内层重新选中。文本被别人改过就不认。
38
+ * deleteAt(ed, off) 只删一个字符,从后往前删,开符那一侧的偏移不会被挪动。 */
39
+
40
+ /** 包裹后按真文本复核:把「不在 open+内层+close 里」的多余开符删掉。
41
+ * 实测 全角 “ 包裹时 Lexical 会按自己的模型再插一个开符,而且**落在文首**
42
+ * (选中 abcd 的 1-3 → `“a“bc”d`),原来的判断只认紧挨着的 `open+open`,所以漏了。 */
43
+ function repairWrapDup(ed, open, inner, close, wantLen) {
44
+ try {
45
+ var t = textOf(ed)
46
+ // 长度不变量:包裹只在选区两侧各加一个字符。文本没变长就说明没有多余开符,
47
+ // 一旦动手就会删掉正确的那一个(半角中文那例就是这么被删坏的)。
48
+ if (typeof wantLen === 'number' && t.length <= wantLen) return
49
+ var want = open + inner + close
50
+ var at = t.indexOf(want)
51
+ if (at < 0) return
52
+ for (var i = 0; i < t.length; i++) {
53
+ if (t.charAt(i) !== open) continue
54
+ if (i >= at && i < at + open.length) continue // 正确那个开符
55
+ deleteAt(ed, i) // 落在地上的多余开符
56
+ t = textOf(ed)
57
+ at = t.indexOf(want)
58
+ break
59
+ }
60
+ if (at >= 0) {
61
+ var endAt = at + want.length
62
+ if (t.charAt(endAt - 1) === close) placeCaretVerified(ed, endAt, t)
63
+ lastWrap && (lastWrap.start = at + open.length)
64
+ }
65
+ } catch (e) {}
66
+ }
67
+
68
+ function tryUnwrap(ed, open) {
69
+ if (!lastWrap || lastWrap.ed !== ed || lastWrap.open !== open) return false
70
+ if (open !== lastWrap.close) return false
71
+ var w = lastWrap
72
+ var text = textOf(ed)
73
+ var start = w.start
74
+ var exact = text.slice(w.start - 1, w.start) === open
75
+ && text.slice(w.end, w.end + 1) === w.close
76
+ && text.slice(w.start, w.end) === w.inner
77
+ if (!exact) {
78
+ // 位置口径在不同编辑器里不一致(textBeforeCaret 可能按选区起点也可能按末端),
79
+ // 所以对不上时按「开符 + 内层 + 闭符」在全文里找一次;找不到才算被改过。
80
+ var at = text.indexOf(open + w.inner + w.close)
81
+ if (at < 0) { lastWrap = null; return false }
82
+ start = at + open.length
83
+ }
84
+ lastWrap = null
85
+ var innerEnd = start + w.inner.length
86
+ // 一次替换,不留给编辑器中间态:选中 open+inner+close,用 inner 覆盖
87
+ if (isText(ed)) {
88
+ ed.selectionStart = start - 1
89
+ ed.selectionEnd = innerEnd + 1
90
+ } else {
91
+ setSelectionRange(ed, start - 1, innerEnd + 1)
92
+ }
93
+ insertText(ed, w.inner)
94
+ if (isText(ed)) {
95
+ ed.selectionStart = start - 1
96
+ ed.selectionEnd = start - 1 + w.inner.length
97
+ } else {
98
+ soon(function () { setSelectionRange(ed, start - 1, start - 1 + w.inner.length) })
99
+ }
100
+ return true
101
+ }
102
+
103
+ /** 多行包裹的第二步:确认「开符 + 内容 + 闭符」齐全,缺闭符就补一个(单字符插入)。
104
+ * 幂等:已经齐了就什么都不做;找不到「开符 + 内容」也放手,不猜位置。 */
105
+ function finishMultilineWrap(ed, open, inner, close) {
106
+ try {
107
+ var t = textOf(ed)
108
+ var want = open + inner + close
109
+ if (t.indexOf(want) >= 0) return
110
+ var head = open + inner
111
+ var at = t.indexOf(head)
112
+ if (at < 0) return
113
+ var pos = at + head.length
114
+ if (t.charAt(pos) === close) return
115
+ setSelectionRange(ed, pos, pos)
116
+ suppressInput++
117
+ insertText(ed, close)
118
+ placeCaretVerified(ed, pos + close.length, t.slice(0, pos) + close + t.slice(pos))
119
+ } catch (e) {}
120
+ }
121
+ function surroundSelection(ed, open, selTxt, close) {
122
+ try {
123
+ if (!isText(ed)) {
124
+ var sel = window.getSelection()
125
+ if (!sel || !sel.rangeCount) return
126
+ var inner = String(selTxt || sel.toString() || '')
127
+ var text = open + inner + close
128
+ // 含换行的选区(两段以上、代码围栏整段)分两步包裹:
129
+ // ① 80ms 后贴「开符 + 内容」—— 同拍贴整段时宿主会把**末尾的引号**吃掉(只丢引号,不丢别的字符);
130
+ // ② 再等 400ms 核对,缺闭符就用单字符插入补上(那时宿主已落定,单字符插入稳定)。
131
+ // 判据:两段文本两侧引号齐全、每次一致;撤销两拍回到原句(单段仍是一拍)。
132
+ if (String(inner).indexOf('\n') >= 0) {
133
+ setTimeout(function () {
134
+ try {
135
+ var selNow = window.getSelection()
136
+ if (!selNow || !selNow.rangeCount || !String(selNow)) return
137
+ var dtM = new DataTransfer()
138
+ dtM.setData('text/plain', open + String(inner))
139
+ ed.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dtM, bubbles: true, cancelable: true }))
140
+ setTimeout(function () { finishMultilineWrap(ed, open, String(inner), close) }, 400)
141
+ } catch (eM) {}
142
+ }, 80)
143
+ return
144
+ }
145
+ var tailLen = 0
146
+ try {
147
+ var r0 = sel.getRangeAt(0)
148
+ var afterR = document.createRange()
149
+ afterR.selectNodeContents(ed)
150
+ afterR.setStart(r0.endContainer, r0.endOffset)
151
+ tailLen = textLenOf(afterR.cloneContents())
152
+ } catch (e5) { tailLen = 0 }
153
+ var handled = false
154
+ try {
155
+ var dt = new DataTransfer()
156
+ dt.setData('text/plain', text)
157
+ var evPaste = new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })
158
+ handled = ed.dispatchEvent(evPaste) === false // false = 被 preventDefault = 编辑器接下了
159
+ } catch (e3) {}
160
+ dbg('wrap-paste', { ed: ed, sel: inner, handled: handled })
161
+ // 兜底只在**宿主没接**时走 execCommand;它含空格会静默不落字,落没落由 wrapSelection 复核
162
+ if (!handled) insertText(ed, text)
163
+ soon(function () { // 等 Lexical 落定,再把光标放到闭符之后
164
+ try {
165
+ var at = flatText(ed).length - tailLen
166
+ if (at >= 0) setSelectionRange(ed, at, at)
167
+ } catch (e4) {}
168
+ })
169
+ return
170
+ }
171
+ insertText(ed, open + selTxt + close)
172
+ var endAt = ed.selectionStart // 文本插入完成后 caret 在整段之后
173
+ ed.selectionStart = ed.selectionEnd = endAt // 就在这里:闭符之后
174
+ } catch (e) {}
175
+ }
176
+
177
+ // 弹层:一个 fixed div,数据来自 words 路由