dsh-composer-ease 0.0.0-stage → 0.9.66
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +180 -0
- package/LICENSE +21 -0
- package/README.i18n.yaml +30 -0
- package/README.md +79 -2
- package/README.zh.md +80 -0
- package/THIRD-PARTY.md +259 -0
- package/cordis.patch.yml +11 -0
- package/lib/cjk-domain.js +24 -0
- package/lib/cjk-lexicon.js +30049 -0
- package/lib/client/10-foundation.js +396 -0
- package/lib/client/20-pairing.js +177 -0
- package/lib/client/30-popup.js +320 -0
- package/lib/client/40-language.js +174 -0
- package/lib/client/45-settings.js +225 -0
- package/lib/client/50-caret.js +254 -0
- package/lib/client/60-keys.js +242 -0
- package/lib/client/70-input.js +369 -0
- package/lib/client/80-apply.js +76 -0
- package/lib/client/_head.js +17 -0
- package/lib/client/_tail.js +3 -0
- package/lib/client.js +2262 -0
- package/lib/domain-words.js +13 -0
- package/lib/en-common.js +54 -0
- package/lib/en-lexicon.js +66 -0
- package/lib/fuzzy.js +485 -0
- package/lib/index.js +438 -0
- package/lib/words.js +88 -0
- package/package.json +63 -4
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// ── 模块:设置项(严格配对 / 引号模式 / 候选类型预设)──────────────────
|
|
2
|
+
// 从 70-input.js 拆出来的(加完候选类型预设后那个片段到 411 行,超结构闸的 400 上限)。
|
|
3
|
+
// 只放「存哪儿 + 怎么读 + 预设怎么切」,不碰事件处理。
|
|
4
|
+
|
|
5
|
+
var PAIR_STRICT_KEY = 'dsh-composer-ease:pairStrict'
|
|
6
|
+
function pairStrict() {
|
|
7
|
+
try { return window.localStorage.getItem(PAIR_STRICT_KEY) === '1' } catch (e) { return false }
|
|
8
|
+
}
|
|
9
|
+
function setPairStrict(on) {
|
|
10
|
+
try { window.localStorage.setItem(PAIR_STRICT_KEY, on ? '1' : '0') } catch (e) {}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/* 引号模式():'warn' = 不自动补闭引号,未闭合时标红提示(VS Code 的
|
|
14
|
+
* 「标红而不是硬改」口径,默认);'auto' = 老行为,打一个引号补一对。 */
|
|
15
|
+
/* 候选类型开关(等价 VS Code `suggest.show*` 一族里插件有的四类):
|
|
16
|
+
* 存「关掉哪些」,默认全开;过滤在候选合并那一处做,索引/灰字/Tab 都跟着同一份列表。 */
|
|
17
|
+
var KIND_KEY = 'dsh-composer-ease:kinds-off'
|
|
18
|
+
var ALL_KINDS = [['word', '词'], ['file', '文件'], ['model', '模型'], ['snippet', '片段']]
|
|
19
|
+
function kindsOff() {
|
|
20
|
+
try { var a = JSON.parse(window.localStorage.getItem(KIND_KEY) || '[]'); return Array.isArray(a) ? a : [] } catch (e) { return [] }
|
|
21
|
+
}
|
|
22
|
+
function isKindOn(k) { return kindsOff().indexOf(k) < 0 }
|
|
23
|
+
function toggleKind(k) {
|
|
24
|
+
var off = kindsOff(), i = off.indexOf(k)
|
|
25
|
+
if (i >= 0) off.splice(i, 1); else off.push(k)
|
|
26
|
+
try { window.localStorage.setItem(KIND_KEY, JSON.stringify(off)) } catch (e) {}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/* 候选类型**预设**:不把四个开关摊成四行(`/` 菜单里太费),一行循环切换。
|
|
30
|
+
* 组合仍是四个 kind 的开关,所以手改 localStorage 的 kinds-off 依然有效。 */
|
|
31
|
+
var KIND_PRESETS = [
|
|
32
|
+
{ label: '全部', off: [] },
|
|
33
|
+
{ label: '无文件', off: ['file'] },
|
|
34
|
+
{ label: '只有词', off: ['file', 'snippet', 'model'] },
|
|
35
|
+
{ label: '全关', off: ['word', 'file', 'model', 'snippet'] },
|
|
36
|
+
]
|
|
37
|
+
function kindPresetIndex() {
|
|
38
|
+
var off = kindsOff().slice().sort().join(',')
|
|
39
|
+
for (var i = 0; i < KIND_PRESETS.length; i++) {
|
|
40
|
+
if (KIND_PRESETS[i].off.slice().sort().join(',') === off) return i
|
|
41
|
+
}
|
|
42
|
+
return -1 // 手工组合:不属于任何预设
|
|
43
|
+
}
|
|
44
|
+
function kindPresetLabel() {
|
|
45
|
+
var i = kindPresetIndex()
|
|
46
|
+
return i >= 0 ? KIND_PRESETS[i].label : '自定义'
|
|
47
|
+
}
|
|
48
|
+
function kindPresetDesc() {
|
|
49
|
+
var i = kindPresetIndex()
|
|
50
|
+
if (i < 0) return '自定义组合(' + ALL_KINDS.filter(function (k) { return isKindOn(k[0]) }).map(function (k) { return k[1] }).join('+') + ')'
|
|
51
|
+
return i === 0 ? '词 + 文件 + 模型 + 片段 全开'
|
|
52
|
+
: i === 1 ? '词 + 模型 + 片段(文件路径不进候选)'
|
|
53
|
+
: i === 2 ? '只留词(词典 / 关键字 / 本机词)'
|
|
54
|
+
: '四类全关(等于关掉补全)'
|
|
55
|
+
}
|
|
56
|
+
function kindPresetNextLabel() {
|
|
57
|
+
var i = kindPresetIndex()
|
|
58
|
+
return KIND_PRESETS[(i + 1) % KIND_PRESETS.length].label
|
|
59
|
+
}
|
|
60
|
+
function cycleKindPreset() {
|
|
61
|
+
var next = KIND_PRESETS[(kindPresetIndex() + 1) % KIND_PRESETS.length]
|
|
62
|
+
try { window.localStorage.setItem(KIND_KEY, JSON.stringify(next.off)) } catch (e) {}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
var QUOTE_MODE_KEY = 'dsh-composer-ease:quoteMode'
|
|
66
|
+
// 全角括号改半角(默认关):中文输入法会把 ( 合成 (,有人要的就是 ASCII 的 ()。
|
|
67
|
+
// 关=尊重输入法打出来的字符(中文正文里 () 才是正确标点)。
|
|
68
|
+
var FW_HALF_KEY = 'dsh-composer-ease:fullwidthHalf'
|
|
69
|
+
// 默认开:中文态打 ( 出来的 ( 直接换成半角 ()(一次反馈:括号会输出成())。
|
|
70
|
+
// 想按中文标点写正文的,在 / 菜单关掉即可。
|
|
71
|
+
function fwHalf() {
|
|
72
|
+
try { return window.localStorage.getItem(FW_HALF_KEY) !== '0' } catch (e) { return true }
|
|
73
|
+
}
|
|
74
|
+
function setFwHalf(on) {
|
|
75
|
+
try { window.localStorage.setItem(FW_HALF_KEY, on ? '1' : '0') } catch (e) {}
|
|
76
|
+
}
|
|
77
|
+
// 默认 auto:没有选区、前方也没有未闭合引号时,单独打一个引号就该得到一对。
|
|
78
|
+
// 标红模式退成显式开关(/ 菜单里切),不再默认关掉配对。
|
|
79
|
+
function quoteMode() {
|
|
80
|
+
try { return window.localStorage.getItem(QUOTE_MODE_KEY) === 'warn' ? 'warn' : 'auto' } catch (e) { return 'auto' }
|
|
81
|
+
}
|
|
82
|
+
function setQuoteMode(m) {
|
|
83
|
+
try { window.localStorage.setItem(QUOTE_MODE_KEY, m === 'auto' ? 'auto' : 'warn') } catch (e) {}
|
|
84
|
+
}
|
|
85
|
+
// ── 设置版本:默认值改过之后,旧默认值不能一直粘在用户身上 ──────────────
|
|
86
|
+
// 实测:旧版默认是「标红提示」,那些用户 localStorage 里存着 warn,升级后打引号
|
|
87
|
+
// 仍然不配对,看上去像插件没修好。换默认值时清掉该格,显式选择从这一版重新记。
|
|
88
|
+
var SCHEMA_KEY = 'dsh-composer-ease:schema'
|
|
89
|
+
var SCHEMA_NOW = '2'
|
|
90
|
+
function settingsKeys() { return [PAIR_STRICT_KEY, KIND_KEY, QUOTE_MODE_KEY, FW_HALF_KEY] }
|
|
91
|
+
function migrateSettings() {
|
|
92
|
+
try {
|
|
93
|
+
if (window.localStorage.getItem(SCHEMA_KEY) === SCHEMA_NOW) return
|
|
94
|
+
window.localStorage.removeItem(QUOTE_MODE_KEY)
|
|
95
|
+
window.localStorage.setItem(SCHEMA_KEY, SCHEMA_NOW)
|
|
96
|
+
} catch (e) {}
|
|
97
|
+
}
|
|
98
|
+
function resetSettings() {
|
|
99
|
+
try {
|
|
100
|
+
for (var i = 0; i < settingsKeys().length; i++) window.localStorage.removeItem(settingsKeys()[i])
|
|
101
|
+
window.localStorage.setItem(SCHEMA_KEY, SCHEMA_NOW)
|
|
102
|
+
} catch (e) {}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/* ── / 菜单:三行设置什么时候出、命令名怎么补 ───────────────────────
|
|
106
|
+
* 宿主那侧只用子序列打分(dsh-web-frontend 里的 rankByName):/comp 出 compact,
|
|
107
|
+
* 但 /compot 这种错拼的子序列会断(整组只剩本插件那三行)。
|
|
108
|
+
* 所以:① 三行也按 query 过滤,别在宿主那组消失后还赖在菜单里;
|
|
109
|
+
* ② 错拼的兜底行由宿主的命令表来补(rescueRows),插件自己不解命令。 */
|
|
110
|
+
function looseScore(query, text) {
|
|
111
|
+
var q = String(query || '').toLowerCase(), t = String(text || '').toLowerCase()
|
|
112
|
+
if (!q) return 0
|
|
113
|
+
if (!t) return -1
|
|
114
|
+
if (t.indexOf(q) === 0) return 1000 - Math.min(999, t.length - q.length) // 前缀命中
|
|
115
|
+
var at = t.indexOf(q)
|
|
116
|
+
if (at > 0) return 800 - Math.min(799, at) // 中段连续命中
|
|
117
|
+
var ti = 0, hit = 0
|
|
118
|
+
for (var qi = 0; qi < q.length; qi++) {
|
|
119
|
+
var ch = q.charAt(qi)
|
|
120
|
+
while (ti < t.length && t.charAt(ti) !== ch) ti++
|
|
121
|
+
if (ti >= t.length) return -1 // 子序列也断:不命中
|
|
122
|
+
ti++; hit++
|
|
123
|
+
}
|
|
124
|
+
return 400 + hit
|
|
125
|
+
}
|
|
126
|
+
/** 错拼容忍:正常命中一律不算(返回 -1),只有「跳过 ≤2 个查询字符、≤2 个目标字符」
|
|
127
|
+
* 才给分数。实测 /compot → compact 恰好是跳 1 个查询字符(o)、2 个目标字符(a c)。 */
|
|
128
|
+
function typoScore(query, text) {
|
|
129
|
+
var q = String(query || '').toLowerCase(), t = String(text || '').toLowerCase()
|
|
130
|
+
if (!q || !t || looseScore(q, t) >= 0) return -1
|
|
131
|
+
var qi = 0, ti = 0, skipQ = 0, skipT = 0, hit = 0
|
|
132
|
+
while (qi < q.length) {
|
|
133
|
+
if (ti >= t.length) { skipQ += q.length - qi; break }
|
|
134
|
+
if (q.charAt(qi) === t.charAt(ti)) { qi++; ti++; hit++; continue }
|
|
135
|
+
var ahead = t.indexOf(q.charAt(qi), ti)
|
|
136
|
+
if (ahead >= 0 && ahead - ti <= 2 && skipT + (ahead - ti) <= 2) { skipT += ahead - ti; ti = ahead; continue }
|
|
137
|
+
skipQ++; qi++
|
|
138
|
+
if (skipQ > 2) return -1
|
|
139
|
+
}
|
|
140
|
+
return hit >= 3 ? hit : -1
|
|
141
|
+
}
|
|
142
|
+
/** 三条设置行(定的口径:/ 菜单是操作入口不是设置面板,一行一条、点一下切一档)。 */
|
|
143
|
+
function settingsRows() {
|
|
144
|
+
var on = pairStrict()
|
|
145
|
+
var qm = quoteMode()
|
|
146
|
+
return [
|
|
147
|
+
{
|
|
148
|
+
name: '引号模式',
|
|
149
|
+
label: qm === 'auto' ? '引号模式:自动补对' : '引号模式:标红提示',
|
|
150
|
+
description: qm === 'auto'
|
|
151
|
+
? '当前:自动补对(打一个引号补一对;严格配对关时右侧有文字则只插一个)'
|
|
152
|
+
: '当前:标红提示(默认;引号未闭合时输入框标红,不替你补)',
|
|
153
|
+
hint: qm === 'auto' ? '点击改为标红提示' : '点击改为自动补对',
|
|
154
|
+
},
|
|
155
|
+
{ name: '候选类型',
|
|
156
|
+
label: '候选类型:' + kindPresetLabel(),
|
|
157
|
+
description: '当前:' + kindPresetDesc(),
|
|
158
|
+
hint: '点击切到:' + kindPresetNextLabel() },
|
|
159
|
+
{
|
|
160
|
+
name: '全角括号',
|
|
161
|
+
label: '全角括号:' + (fwHalf() ? '打成半角 ()' : '保持全角()'),
|
|
162
|
+
description: fwHalf()
|
|
163
|
+
? '当前:输入法打 ( 得到半角 ()(写正文想要中文标点就关掉这一项)'
|
|
164
|
+
: '当前:保持输入法给的字符(中文正文里 () 是正确标点)',
|
|
165
|
+
hint: fwHalf() ? '点击改为保持全角' : '点击改为打成半角 ()',
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
name: '重置插件设置',
|
|
169
|
+
label: '重置插件设置',
|
|
170
|
+
description: '把本插件存下的选择(引号模式 / 候选类型 / 严格配对)清回默认',
|
|
171
|
+
hint: '点击立即重置',
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
name: '严格配对',
|
|
175
|
+
label: '严格配对',
|
|
176
|
+
description: on ? '当前:开(引号必须前后成对,混全角半角也算一对)' : '当前:关(默认,遇到已有引号就不再补对)',
|
|
177
|
+
hint: on ? '点击关闭' : '点击开启',
|
|
178
|
+
},
|
|
179
|
+
]
|
|
180
|
+
}
|
|
181
|
+
/** 取不到宿主命令表时的兜底:本机只有这三条内置命令(dsh-command-compact/goal/feedback)。 */
|
|
182
|
+
var BUILTIN_COMMANDS = [['compact', '压缩上下文'], ['goal', '目标'], ['feedback', '反馈']]
|
|
183
|
+
/** 错拼兜底行:宿主那份排名没排出来的命令,按「跳字」判据补一行(正常拼写不重复出)。 */
|
|
184
|
+
function rescueRows(query, host, leading) {
|
|
185
|
+
var q = String(query || '')
|
|
186
|
+
if (!q) return []
|
|
187
|
+
var matched = {}
|
|
188
|
+
var seen = host && host.matched ? host.matched : []
|
|
189
|
+
for (var i = 0; i < seen.length; i++) if (seen[i] && seen[i].name) matched[seen[i].name] = 1
|
|
190
|
+
var pool = (host && host.all && host.all.length) ? host.all : BUILTIN_COMMANDS.map(function (c) {
|
|
191
|
+
return { name: c[0], label: c[1] }
|
|
192
|
+
})
|
|
193
|
+
var out = []
|
|
194
|
+
for (var j = 0; j < pool.length; j++) {
|
|
195
|
+
var r = pool[j]
|
|
196
|
+
if (!r || !r.name || matched[r.name]) continue // 宿主已经排出来的行不再重复
|
|
197
|
+
// 与宿主同一条位置判据:带输入提示的命令只在行首列(行内打 / 时不列)
|
|
198
|
+
if (!leading && r.hint) continue
|
|
199
|
+
var score = Math.max(typoScore(q, r.name), typoScore(q, r.label || ''))
|
|
200
|
+
if (score < 0) continue
|
|
201
|
+
out.push({
|
|
202
|
+
name: r.name,
|
|
203
|
+
label: '/' + r.name + (r.label ? ' ' + r.label : ''),
|
|
204
|
+
description: String(r.description || ''),
|
|
205
|
+
hint: '错拼兜底',
|
|
206
|
+
rescue: r.name,
|
|
207
|
+
})
|
|
208
|
+
if (out.length >= 3) break
|
|
209
|
+
}
|
|
210
|
+
return out
|
|
211
|
+
}
|
|
212
|
+
function triggerBasename(rel) {
|
|
213
|
+
var t = String(rel || '')
|
|
214
|
+
var i = t.lastIndexOf('/')
|
|
215
|
+
return i >= 0 ? t.slice(i + 1) : t
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** 直接问一次只读路由,拿原始候选(不做 DOM 渲染,供触发源用)。 */
|
|
219
|
+
function fetchItems(prefix) {
|
|
220
|
+
var url = ROUTE_WORDS + '?prefix=' + encodeURIComponent(prefix || '') + '&lang='
|
|
221
|
+
return fetch(url).then(function (r) { return r.json() }).then(function (d) {
|
|
222
|
+
return d ? (Array.isArray(d.candidates) ? d.candidates : (Array.isArray(d.items) ? d.items : [])) : []
|
|
223
|
+
}).catch(function () { return [] })
|
|
224
|
+
}
|
|
225
|
+
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
// ── 模块:光标复位与接受 ────────────────────────────────
|
|
2
|
+
// placeCaretVerified / flushCaretFix / afterPairCheck / ensureCaretAt:插入后把光标放回该在的位置,以及 acceptCurrent。
|
|
3
|
+
// 本文件是 lib/client.js 的片段,由 tools/build-client.mjs 按序拼回;不要直接改生成物 lib/client.js。
|
|
4
|
+
|
|
5
|
+
var suppressInput = 0 // 我们自己插入文本后会派发 input;别让它回来又开一次弹层(连按即滚雪球)
|
|
6
|
+
var caretEpoch = 0 // 每次按键 +1;延后的光标复位只认最后一次
|
|
7
|
+
/* IME 合成中:拼音还没上屏,这时候的拉丁字母不是「输入内容」,不能触发补全。
|
|
8
|
+
* 实测:打拼音 `ye` 时 JS 关键字 `yield` 被前缀命中,弹层+灰字 `eld`
|
|
9
|
+
* 盖在输入法候选窗旁边。VS Code 同样在 composition 期间不出建议。 */
|
|
10
|
+
var composing = false
|
|
11
|
+
/* 光标复位要**跨帧校验**:实测(demo 3081)配对后只调一次 `setSelectionRange`
|
|
12
|
+
* 会被 Lexical 随后的一次重渲染冲掉 —— 空框打 `"` 拿到 `""` 但 caret 停在 0(开符之前),
|
|
13
|
+
* 于是接着打字打到引号外面、连按第三下也被当成「跳过闭符」吞掉。
|
|
14
|
+
* 这里按绝对偏移写几次,每次都读回来确认;期间只要又按了键(caretEpoch 变)就立刻放手,
|
|
15
|
+
* 不去跟用户抢光标。文本域是同步的,写一次就返回。 */
|
|
16
|
+
/* 光标复位:时机由 **Lexical 的 DOM 变化**来定,不能猜 —— 插件插入的文本节点在 microtask / rAF /
|
|
17
|
+
* `setTimeout 0` 那一刻都还不存在,约 2ms 后才出现。盲目撒定时器两头出错:等不到那一格会被输入法
|
|
18
|
+
* 上屏抢先(文字落到闭符外面),长尾回调又会在用户打完字后把光标拽回原位。
|
|
19
|
+
* 做法:MutationObserver 等文本落地,再用**文本护栏**(`expect`)判断有没有人插手。 */
|
|
20
|
+
var caretFix = null
|
|
21
|
+
var caretObserver = null, caretObserverTarget = null
|
|
22
|
+
function ensureCaretObserver(ed) {
|
|
23
|
+
if (typeof MutationObserver === 'undefined') return
|
|
24
|
+
try {
|
|
25
|
+
if (caretObserver && caretObserverTarget === ed) return
|
|
26
|
+
if (caretObserver) caretObserver.disconnect()
|
|
27
|
+
caretObserver = new MutationObserver(function () { flushCaretFix() })
|
|
28
|
+
caretObserver.observe(ed, { childList: true, subtree: true, characterData: true })
|
|
29
|
+
caretObserverTarget = ed
|
|
30
|
+
} catch (e) { caretObserver = null }
|
|
31
|
+
}
|
|
32
|
+
function flushCaretFix() {
|
|
33
|
+
var f = caretFix
|
|
34
|
+
if (!f) return
|
|
35
|
+
if (f.epoch !== caretEpoch) { caretFix = null; return } // 期间有按键/输入法接管:放手
|
|
36
|
+
if (f.tries === undefined) f.tries = 0
|
|
37
|
+
var now
|
|
38
|
+
try { now = textOf(f.ed) } catch (e) { return }
|
|
39
|
+
if (now !== f.expect) {
|
|
40
|
+
if (now.length < f.expect.length) return // 自己的插入还没落地,等下一次 mutation
|
|
41
|
+
caretFix = null; return // 文本被别人改过:不猜,放手
|
|
42
|
+
}
|
|
43
|
+
var at = -1
|
|
44
|
+
try {
|
|
45
|
+
if (caretPos(f.ed) !== f.target) setCaret(f.ed, f.target)
|
|
46
|
+
at = caretPos(f.ed)
|
|
47
|
+
} catch (e) { at = -1 }
|
|
48
|
+
/* **要连续两拍都读到目标位置、而且文本没再变**才算落地:
|
|
49
|
+
* Lexical 的重渲染比插件的写入晚一拍(实测 ~2ms),只写一次会被它冲掉 ——
|
|
50
|
+
* 那正是「空框打全角开符,配对后光标停在闭符之后」的实测值来源。 */
|
|
51
|
+
if (at === f.target && f.lastText === now) f.stable = (f.stable || 0) + 1
|
|
52
|
+
else f.stable = 0
|
|
53
|
+
f.lastText = now
|
|
54
|
+
if (f.stable >= 2) {
|
|
55
|
+
caretFix = null
|
|
56
|
+
if (typeof f.then === 'function') { try { f.then() } catch (e) {} } // 落地后的复核
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
if (++f.tries < 10) soon(flushCaretFix)
|
|
60
|
+
else caretFix = null
|
|
61
|
+
}
|
|
62
|
+
/** 配对之后的**结果复核**:按「落地后的真文本」把判据重算一遍。
|
|
63
|
+
* 读到的状态可能是旧的(Lexical 重渲染的空窗),但结果不能跟着旧走 ——
|
|
64
|
+
* 本该「只插一个」的,就把多出来的闭符撤掉。判据在真文本上成立时这个函数什么都不做
|
|
65
|
+
* (这正是正常路径),只在读到旧状态时才纠正。 */
|
|
66
|
+
function afterPairCheck(ed, open, close, atOpen) {
|
|
67
|
+
return function () {
|
|
68
|
+
try {
|
|
69
|
+
var t = textOf(ed)
|
|
70
|
+
if (t.slice(atOpen, atOpen + 2) !== open + close) return // 已经不是我们插的那一对了
|
|
71
|
+
var b = t.slice(0, atOpen), n = t.charAt(atOpen + 2)
|
|
72
|
+
if (!isUnbalancedBefore(b, open, close) && shouldPair(b, n, open)) return
|
|
73
|
+
suppressInput++
|
|
74
|
+
deleteAt(ed, atOpen + 1) // 撤掉闭符 → 只剩一个开符
|
|
75
|
+
placeCaretVerified(ed, atOpen + 1, t.slice(0, atOpen + 1) + t.slice(atOpen + 2))
|
|
76
|
+
ensureCaretAt(ed, atOpen + 1)
|
|
77
|
+
} catch (e) {}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/** 插一段文本:优先用粘贴事件(Lexical 把它当一次原子编辑,落点稳定),不行再 execCommand。
|
|
81
|
+
* 专门给"配对"这种紧跟下一键的场景用。 */
|
|
82
|
+
function insertAtomic(ed, text) {
|
|
83
|
+
var handled = false
|
|
84
|
+
try {
|
|
85
|
+
var dt = new DataTransfer()
|
|
86
|
+
dt.setData('text/plain', text)
|
|
87
|
+
var evPaste = new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })
|
|
88
|
+
handled = ed.dispatchEvent(evPaste) === false
|
|
89
|
+
} catch (e) { handled = false }
|
|
90
|
+
if (!handled) insertText(ed, text)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** 读**实际**光标在编辑器纯文本里的偏移;读不到回 -1。 */
|
|
94
|
+
function caretOffsetNow(ed) {
|
|
95
|
+
try {
|
|
96
|
+
if (isText(ed)) return ed.selectionStart
|
|
97
|
+
var sel = window.getSelection()
|
|
98
|
+
if (!sel || !sel.rangeCount) return -1
|
|
99
|
+
var r = sel.getRangeAt(0)
|
|
100
|
+
if (!r.collapsed) return -1
|
|
101
|
+
var pre = document.createRange()
|
|
102
|
+
pre.selectNodeContents(ed)
|
|
103
|
+
pre.setEnd(r.startContainer, r.startOffset)
|
|
104
|
+
return textLenOf(pre.cloneContents())
|
|
105
|
+
} catch (e) { return -1 }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** 把光标按**实际偏移**钉到 target:不对就硬设,再按梯子复验几拍。
|
|
109
|
+
* 配对那条路不能只信"预期偏移"——编辑器可能自己挪过光标(全角引号就是这种情况)。 */
|
|
110
|
+
function ensureCaretAt(ed, target, tries) {
|
|
111
|
+
var left = tries === undefined ? 3 : tries
|
|
112
|
+
var step = function (n) {
|
|
113
|
+
if (target < 0) return
|
|
114
|
+
if (caretOffsetNow(ed) === target) return
|
|
115
|
+
try { setSelectionRange(ed, target, target) } catch (e) {}
|
|
116
|
+
// 让编辑器(Lexical)把模型选区也同步过来,否则下一键会按它自己的模型落字
|
|
117
|
+
try { document.dispatchEvent(new Event('selectionchange')) } catch (e2) {}
|
|
118
|
+
if (n > 0) soon(function () { step(n - 1) })
|
|
119
|
+
}
|
|
120
|
+
step(left)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** 把光标放到 target,但要等 `expect` 这段文本落地之后才动手。
|
|
124
|
+
* `expect` = 插件自己这一次插入完成后的**整段文本**;文本长度更短=还没落地(继续等),
|
|
125
|
+
* 内容对不上=别人(用户/输入法)插手了(放手,绝不抢光标)。
|
|
126
|
+
* `then` = 落地后跑一次的复核。 */
|
|
127
|
+
function placeCaretVerified(ed, target, expect, then) {
|
|
128
|
+
if (target < 0) return
|
|
129
|
+
var epoch = ++caretEpoch
|
|
130
|
+
if (isText(ed)) {
|
|
131
|
+
try { ed.selectionStart = ed.selectionEnd = target } catch (e) {}
|
|
132
|
+
if (typeof then === 'function') { try { then() } catch (e) {} }
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
caretFix = { ed: ed, target: target, expect: String(expect === undefined ? '' : expect), epoch: epoch, then: then }
|
|
136
|
+
ensureCaretObserver(ed)
|
|
137
|
+
flushCaretFix() // 文本已经在位的情况(跳闭符那条路没有文本变化)
|
|
138
|
+
soon(function () { // 兜底:万一等不到 mutation
|
|
139
|
+
if (caretFix && caretFix.epoch === caretEpoch) flushCaretFix()
|
|
140
|
+
})
|
|
141
|
+
}
|
|
142
|
+
/** 片段占位符的待跳表:接受片段后填上,Tab 依次跳,任何其它按键/失焦清空。 */
|
|
143
|
+
var snippetStops = null, snippetEpoch = 0
|
|
144
|
+
|
|
145
|
+
/* 把光标前 [start, 光标) 这一段换成 text,并**核对结果**。
|
|
146
|
+
* 一次反馈:接受后原文被吞掉、候选没进去,只能 Ctrl+Z 找回。
|
|
147
|
+
* 真因是 contenteditable 这条路把「选 token → 插候选」交给 execCommand,而宿主 Lexical
|
|
148
|
+
* 有时不认插件摆的选区(实测:打 文档 再 Tab 得到「文档管理文档」,多一份原文;
|
|
149
|
+
* 另有一次候选一个字都没落进去)。所以:落一次 → 比整段文本 → 不对就 undo 这一笔,
|
|
150
|
+
* 原文原地留着 —— 接受要么正确,要么什么都没发生。 */
|
|
151
|
+
function spliceDraft(ed, start, text) {
|
|
152
|
+
var before = textOf(ed)
|
|
153
|
+
var caret = caretPos(ed)
|
|
154
|
+
var end = caret >= 0 ? caret : before.length
|
|
155
|
+
if (start < 0 || start > end) start = end
|
|
156
|
+
var expected = before.slice(0, start) + text + before.slice(end)
|
|
157
|
+
var multi = String(text).indexOf('\n') >= 0
|
|
158
|
+
suppressInput++
|
|
159
|
+
if (isText(ed)) {
|
|
160
|
+
try { ed.selectionStart = start; ed.selectionEnd = end } catch (e) {}
|
|
161
|
+
insertRich(ed, text)
|
|
162
|
+
return textOf(ed) === expected
|
|
163
|
+
}
|
|
164
|
+
try {
|
|
165
|
+
setSelectionRange(ed, start, end)
|
|
166
|
+
insertRich(ed, text) // 多行走 paste 通道,单行走 execCommand
|
|
167
|
+
if (textOf(ed) === expected) return true
|
|
168
|
+
if (multi) { // 宿主的 paste 是异步落的:给它一拍再核
|
|
169
|
+
soon(function () {
|
|
170
|
+
try { if (textOf(ed) !== expected) document.execCommand('undo') } catch (e2) {}
|
|
171
|
+
})
|
|
172
|
+
return true
|
|
173
|
+
}
|
|
174
|
+
if (before !== textOf(ed)) { try { document.execCommand('undo') } catch (e3) {} }
|
|
175
|
+
return false
|
|
176
|
+
} catch (e) { return false }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function acceptCurrent() {
|
|
180
|
+
var it = popup.items[popup.index]
|
|
181
|
+
var ed = lastComposer
|
|
182
|
+
if (!it || !ed) { closePopup(); return }
|
|
183
|
+
if (it.lang) habitBump(it.lang, 2) // 接受过哪套词表的候选,就算在用哪门语言
|
|
184
|
+
// token 必须在**接受这一拍**现算:lastToken 是弹层触发时的快照,
|
|
185
|
+
// 打字比弹层快时会过期(实测:打 console.log 只回删了 'log',前面的 'console.' 留在原地)。
|
|
186
|
+
var tok = tokenBefore(textBeforeCaret(ed)) || lastToken || ''
|
|
187
|
+
var caret0 = caretPos(ed)
|
|
188
|
+
var start0 = (caret0 >= 0 && tok) ? Math.max(0, caret0 - tok.length) : caret0
|
|
189
|
+
var payload = String(it.kind === 'file' ? String(it.value).replace(/\\/g, '/') : it.value)
|
|
190
|
+
var stops = []
|
|
191
|
+
if (it.kind === 'snippet') {
|
|
192
|
+
var ex = expandSnippet(payload)
|
|
193
|
+
payload = ex.text
|
|
194
|
+
stops = ex.stops
|
|
195
|
+
}
|
|
196
|
+
snippetStops = null
|
|
197
|
+
// 没落成就把原文留着、弹层也留着(换一条再来),Tab 不会看起来像没反应
|
|
198
|
+
if (!spliceDraft(ed, start0, payload)) return
|
|
199
|
+
closePopup()
|
|
200
|
+
if (stops.length && start0 >= 0) {
|
|
201
|
+
// 落点基准是**替换区间的起点**,不是插入前的光标位置(早先按光标算,光标会落在文本末尾)
|
|
202
|
+
snippetStops = stops.map(function (s) { return start0 + s })
|
|
203
|
+
snippetEpoch = caretEpoch
|
|
204
|
+
// 先同步落一次(异步观察者赶不上下一拍),再用 placeCaretVerified 兜住编辑器把光标甩到末尾的情况
|
|
205
|
+
try { setSelectionRange(ed, snippetStops[0], snippetStops[0]) } catch (e) {}
|
|
206
|
+
placeCaretVerified(ed, snippetStops[0])
|
|
207
|
+
// 再补一拍:Lexical 往往在插入落地后才把光标甩到末尾,placeCaretVerified 的观察者赶不上它
|
|
208
|
+
var stop0 = snippetStops[0]
|
|
209
|
+
soon(function () {
|
|
210
|
+
if (!snippetStops || snippetStops[0] !== stop0) return
|
|
211
|
+
try { setSelectionRange(ed, stop0, stop0) } catch (e) {}
|
|
212
|
+
})
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
var lastComposer = null, lastToken = '', lastSelText = ''
|
|
217
|
+
|
|
218
|
+
/* 语言习惯:观察实际在用什么语言(围栏标记、以及接受过哪套词表的候选),
|
|
219
|
+
* 存本地,下一次没写围栏时就按习惯上词表 —— 原话「通过观察用户操作习惯选择使用哪个语言」。 */
|
|
220
|
+
var HABIT_KEY = 'dsh-composer-ease:lang-habit'
|
|
221
|
+
function habitRead() { try { return JSON.parse(window.localStorage.getItem(HABIT_KEY) || '{}') || {} } catch (e) { return {} } }
|
|
222
|
+
function habitBump(lang, weight) {
|
|
223
|
+
if (!lang) return
|
|
224
|
+
try {
|
|
225
|
+
var h = habitRead(); h[lang] = (h[lang] || 0) + (weight || 1)
|
|
226
|
+
window.localStorage.setItem(HABIT_KEY, JSON.stringify(h))
|
|
227
|
+
} catch (e) {}
|
|
228
|
+
}
|
|
229
|
+
function habitTop() {
|
|
230
|
+
var h = habitRead(), best = '', n = 0
|
|
231
|
+
for (var k in h) { if (h[k] > n) { best = k; n = h[k] } }
|
|
232
|
+
return best
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/* 调试开关(默认关):localStorage['dsh-composer-ease:debug']='1' 后刷新页面即开。
|
|
236
|
+
* 存在的唯一理由:一次反馈在本机 Playwright 上复现不出来
|
|
237
|
+
* (造不出真实输入法的 composition 事件),所以把实测上那几个字段原样打出来。 */
|
|
238
|
+
var DBG = (function () {
|
|
239
|
+
try { return window.localStorage.getItem('dsh-composer-ease:debug') === '1' } catch (e) { return false }
|
|
240
|
+
})()
|
|
241
|
+
function dbg(kind, o) {
|
|
242
|
+
if (!DBG) return
|
|
243
|
+
try {
|
|
244
|
+
var ed = o && o.ed
|
|
245
|
+
var t = ed ? textOf(ed) : ''
|
|
246
|
+
var sel = ed ? [caretOffset(ed), selectedText(ed)] : ['-', '-']
|
|
247
|
+
console.log('[cpe] ' + kind, JSON.stringify({
|
|
248
|
+
key: o.key, data: o.data, itype: o.itype, composing: !!o.composing,
|
|
249
|
+
before: o.before, sel: sel[1], caret: sel[0], text: t,
|
|
250
|
+
action: o.action, prevented: !!o.prevented
|
|
251
|
+
}))
|
|
252
|
+
} catch (e) {}
|
|
253
|
+
}
|
|
254
|
+
|