collab-canvas 0.3.0 → 0.4.0

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,291 @@
1
+ // ═══ 话布文档模型(P1:块级模型 + markdown 无损往返)═══════════════════
2
+ //
3
+ // 目标(2026-10-15 主人选定"乙"):
4
+ // **语义等价 + 规范化一次后稳定** ✓
5
+ // · 第一次 parse→serialize 可能整理写法(列表标记统一成 - ✓ 空行统一成一个 ✓)
6
+ // · 之后【再往返完全一致】✓(幂等 ✓)—— 也就是"最多动一次,之后不再漂"
7
+ //
8
+ // 为什么只做块级 ✗(P1 的范围):
9
+ // 这次的"有损"是**块级漂移** ✗(日志:行数 308→311,差 100 行 ✓)
10
+ // —— 多行段落被拆、列表套代码块漂移之类 ✓ 那都是块级 ✓
11
+ // 行内(**粗体** / 链接 / 脚注)目前往返是好的 ✓ 先当**原始文本**存 ✓
12
+ // 等 P2 编辑器需要行内结构时再加 ✓(范围小、风险低 ✓)
13
+ //
14
+ // 自包含 ✓ 零依赖 ✓ —— 与 lib/ 下其它侧模块同一种写法 ✓
15
+ // 浏览器:window.__ccvDocModel = CCV_DOC_MODEL ✓
16
+ // node :new Function(src + '\nreturn CCV_DOC_MODEL')() ✓(测试就这么用 ✓)
17
+ var CCV_DOC_MODEL = (function () {
18
+ 'use strict'
19
+
20
+ // ─── 解析:markdown → Doc ───────────────────────────────────────
21
+ // Doc = { blocks: [ Block ] }
22
+ // Block:
23
+ // { type:'h', level:1-6, text }
24
+ // { type:'p', text } ← text 是**原始 markdown 行**(行内不建模 ✓)
25
+ // { type:'code', lang, text }
26
+ // { type:'quote', blocks:[Block] } ← 递归 ✓
27
+ // { type:'list', ordered, items:[{blocks:[Block]}] }
28
+ // { type:'table', align:[…], rows:[[cell,…]], widths:null|[%…], heights:null|[…] }
29
+ // { type:'hr' }
30
+ // { type:'html', text } ← 原始 HTML 块(脚注 / div / mark 等 ✓)
31
+ function parse(md) {
32
+ var lines = String(md == null ? '' : md).replace(/\r\n?/g, '\n').split('\n')
33
+ var blocks = parseBlocks(lines)
34
+ return { blocks: blocks }
35
+ }
36
+
37
+ function isBlank(l) { return /^\s*$/.test(l) }
38
+ function fenceOpen(l) { var m = l.match(/^\s*(```+|~~~+)\s*([^\s`]*)\s*$/); return m ? { fence: m[1], lang: m[2] || '' } : null }
39
+ function isHr(l) { return /^\s{0,3}(-{3,}|\*{3,}|_{3,})\s*$/.test(l) }
40
+ function headingOf(l) { var m = l.match(/^\s{0,3}(#{1,6})\s+(.*)$/); return m ? { level: m[1].length, text: m[2].replace(/\s+$/, '') } : null }
41
+ function isQuote(l) { return /^\s{0,3}>/.test(l) }
42
+ function isTableSep(l) { return /^\s*\|?[\s:|-]*-[\s:|-]*\|?\s*$/.test(l) && l.indexOf('-') >= 0 && l.indexOf('|') >= 0 }
43
+ function isTableRow(l) { return l.indexOf('|') >= 0 && !isBlank(l) }
44
+ // 列表项:- / * / + 或 1. / 1) ;带缩进
45
+ function listItemOf(l) {
46
+ var m = l.match(/^(\s*)([-*+]|\d{1,9}[.)])\s+(.*)$/)
47
+ if (!m) return null
48
+ var ordered = /\d/.test(m[2])
49
+ return { indent: m[1].replace(/\t/g, ' ').length, ordered: ordered, text: m[3], marker: m[2] }
50
+ }
51
+ // 原始 HTML 块:以 <tag 或 <!-- 开头(到空行为止;不做完整 HTML 解析 ✓ 够用 ✓)
52
+ function isHtmlStart(l) {
53
+ return /^\s*<(div|p|table|details|summary|section|figure|blockquote|ul|ol|pre|mark|span|a|img|br|hr|sub|sup|kbd|dl|dt|dd|aside|nav|header|footer|video|audio|iframe|script|style)\b/i.test(l) ||
54
+ /^\s*<!--/.test(l)
55
+ }
56
+
57
+ function parseBlocks(lines) {
58
+ var out = []
59
+ var i = 0
60
+ while (i < lines.length) {
61
+ var l = lines[i]
62
+ if (isBlank(l)) { i++; continue }
63
+
64
+ // ① 围栏代码块
65
+ var fo = fenceOpen(l)
66
+ if (fo) {
67
+ var buf = []
68
+ i++
69
+ while (i < lines.length && !new RegExp('^\\s*' + fo.fence.charAt(0) + '{3,}\\s*$').test(lines[i])) { buf.push(lines[i]); i++ }
70
+ if (i < lines.length) i++ // 吃掉收尾围栏
71
+ out.push({ type: 'code', lang: fo.lang, text: buf.join('\n') })
72
+ continue
73
+ }
74
+
75
+ // ② 标题
76
+ var h = headingOf(l)
77
+ if (h) { out.push({ type: 'h', level: h.level, text: h.text }); i++; continue }
78
+
79
+ // ③ 分隔线
80
+ if (isHr(l)) { out.push({ type: 'hr' }); i++; continue }
81
+
82
+ // ④ 表格:本行含 | 且下一行是分隔行
83
+ if (isTableRow(l) && i + 1 < lines.length && isTableSep(lines[i + 1])) {
84
+ var rows = [], align = []
85
+ var sepCells = splitRow(lines[i + 1])
86
+ for (var a = 0; a < sepCells.length; a++) {
87
+ var s = sepCells[a].trim()
88
+ var lf = s.charAt(0) === ':', rt = s.charAt(s.length - 1) === ':'
89
+ align.push(lf && rt ? 'center' : (rt ? 'right' : (lf ? 'left' : '')))
90
+ }
91
+ rows.push(splitRow(l))
92
+ i += 2
93
+ while (i < lines.length && isTableRow(lines[i]) && !isBlank(lines[i])) { rows.push(splitRow(lines[i])); i++ }
94
+ // 紧跟的列宽/行高注释 ✓(我们自己的标记 ✓ 不算正文 ✓)
95
+ var widths = null, heights = null
96
+ while (i < lines.length) {
97
+ var c = lines[i].trim()
98
+ var mw = c.match(/^<!--\s*ccv-table:\s*([\d.,\s]+?)\s*-->$/)
99
+ var mh = c.match(/^<!--\s*ccv-table-h:\s*([\d.,auto\s]+?)\s*-->$/)
100
+ if (mw) { widths = mw[1].split(',').map(function (x) { return parseFloat(x) || 0 }); i++; continue }
101
+ if (mh) { heights = mh[1].split(',').map(function (x) { var v = String(x).trim(); return (v === 'auto' || !(parseFloat(v) > 0)) ? 0 : Math.round(parseFloat(v)) }); i++; continue }
102
+ break
103
+ }
104
+ out.push({ type: 'table', align: align, rows: rows, widths: widths, heights: heights })
105
+ continue
106
+ }
107
+
108
+ // ⑤ 引用(连续 > 行 → 内部递归 ✓)
109
+ if (isQuote(l)) {
110
+ var q = []
111
+ while (i < lines.length && (isQuote(lines[i]) || (q.length && !isBlank(lines[i]) && !headingOf(lines[i]) && !isHr(lines[i]) && !listItemOf(lines[i]) && !fenceOpen(lines[i])))) {
112
+ q.push(lines[i].replace(/^\s{0,3}>\s?/, ''))
113
+ i++
114
+ }
115
+ out.push({ type: 'quote', blocks: parseBlocks(q) })
116
+ continue
117
+ }
118
+
119
+ // ⑥ 列表(含嵌套缩进 ✓ 连续项算一个列表 ✓)
120
+ var li = listItemOf(l)
121
+ if (li) {
122
+ var items = []
123
+ var ordered = li.ordered
124
+ var startNo = li.ordered ? (parseInt(String(li.marker).replace(/[.)]$/, ''), 10) || 1) : 1
125
+ while (i < lines.length) {
126
+ var it = listItemOf(lines[i])
127
+ if (!it) break
128
+ var itemLines = [it.text]
129
+ var baseIndent = it.indent
130
+ i++
131
+ // 该项的续行
132
+ // ⚠️ 空行后面只要**缩进更深**就仍属于这一项 ✓
133
+ // —— 2026-10-15 修:原来只认"下一行还是列表项"✗
134
+ // 于是「- 项\n\n ```代码块```」里的代码块被拉出列表 ✗
135
+ while (i < lines.length) {
136
+ if (isBlank(lines[i])) {
137
+ var nx = lines[i + 1] || ''
138
+ if (isBlank(nx)) break
139
+ var nxIndent = (nx.match(/^\s*/) || [''])[0].replace(/\t/g, ' ').length
140
+ if (nxIndent > baseIndent) { itemLines.push(''); i++; continue }
141
+ break
142
+ }
143
+ var nIt = listItemOf(lines[i])
144
+ if (nIt && nIt.indent <= baseIndent) break
145
+ var lineIndent = (lines[i].match(/^\s*/) || [''])[0].replace(/\t/g, ' ').length
146
+ if (lineIndent <= baseIndent && !/^\s{2,}/.test(lines[i])) break
147
+ // 保留相对缩进 ✓(至少去掉 baseIndent+2 的前缀 ✓)
148
+ itemLines.push(lines[i].replace(new RegExp('^\\s{0,' + (baseIndent + 2) + '}'), ''))
149
+ i++
150
+ }
151
+ // 去掉尾部空行 ✓
152
+ while (itemLines.length && isBlank(itemLines[itemLines.length - 1])) itemLines.pop()
153
+ items.push({ blocks: parseBlocks(itemLines) })
154
+ // 跳过一个空行继续找下一项 ✓
155
+ var save = i
156
+ while (i < lines.length && isBlank(lines[i])) i++
157
+ var after = listItemOf(lines[i] || '')
158
+ if (!after) { i = save; break }
159
+ }
160
+ out.push({ type: 'list', ordered: ordered, start: startNo, items: items })
161
+ continue
162
+ }
163
+
164
+ // ⑦ 原始 HTML 块
165
+ if (isHtmlStart(l)) {
166
+ var hb = []
167
+ while (i < lines.length && !isBlank(lines[i])) { hb.push(lines[i]); i++ }
168
+ out.push({ type: 'html', text: hb.join('\n') })
169
+ continue
170
+ }
171
+
172
+ // ⑧ 段落:连续非空行(到空行/其它块开头为止 ✓)
173
+ var pb = [l]
174
+ i++
175
+ while (i < lines.length && !isBlank(lines[i]) &&
176
+ !headingOf(lines[i]) && !isHr(lines[i]) && !fenceOpen(lines[i]) &&
177
+ !isQuote(lines[i]) && !listItemOf(lines[i]) && !isHtmlStart(lines[i]) &&
178
+ !(isTableRow(lines[i]) && i + 1 < lines.length && isTableSep(lines[i + 1]))) {
179
+ pb.push(lines[i]); i++
180
+ }
181
+ out.push({ type: 'p', text: pb.join('\n') })
182
+ }
183
+ return out
184
+ }
185
+
186
+ // 拆一行表格:| a | b | → ['a','b'](保留转义 \| ✓)
187
+ // ⚠️ **必须与渲染器一致**(2026-10-15 结论 ✓):
188
+ // lib/client.js 的 buildTable 是 `raw.split("|")` —— **见 | 就拆** ✗
189
+ // 我一度加了"反引号里不拆" ✗ → 模型与视图各说各话 ✗
190
+ // → 这里保持"见 | 就拆"✓ 与渲染器一字不差 ✓
191
+ function splitRow(l) {
192
+ var s = String(l).trim().replace(/^\|/, '').replace(/\|$/, '')
193
+ var cells = [], cur = ''
194
+ for (var i = 0; i < s.length; i++) {
195
+ var ch = s.charAt(i)
196
+ if (ch === '\\' && s.charAt(i + 1) === '|') { cur += '\\|'; i++; continue }
197
+ if (ch === '|') { cells.push(cur.trim()); cur = ''; continue }
198
+ cur += ch
199
+ }
200
+ cells.push(cur.trim())
201
+ return cells
202
+ }
203
+
204
+ // ─── 序列化:Doc → markdown(规范形式 ✓ 幂等的关键 ✓)────────────
205
+ function serialize(doc) {
206
+ var blocks = (doc && doc.blocks) || []
207
+ var parts = []
208
+ for (var i = 0; i < blocks.length; i++) {
209
+ var s = serBlock(blocks[i])
210
+ if (s !== '') parts.push(s)
211
+ }
212
+ return parts.join('\n\n') + '\n'
213
+ }
214
+
215
+ function serBlock(b) {
216
+ if (!b) return ''
217
+ switch (b.type) {
218
+ case 'h': return repeat('#', Math.max(1, Math.min(6, b.level || 1))) + ' ' + b.text
219
+ case 'hr': return '---'
220
+ case 'p': return b.text
221
+ case 'code': return '```' + (b.lang || '') + '\n' + b.text + '\n```'
222
+ case 'html': return b.text
223
+ case 'quote': {
224
+ var inner = serialize({ blocks: b.blocks }).replace(/\n$/, '')
225
+ return inner.split('\n').map(function (l) { return l === '' ? '>' : '> ' + l }).join('\n')
226
+ }
227
+ case 'list': {
228
+ var marker = b.ordered ? null : '-'
229
+ var out = []
230
+ var startN = b.ordered ? (b.start || 1) : 1 // ★ 起始序号要保 ✓(4. 不能写成 1. ✗)
231
+ for (var i = 0; i < b.items.length; i++) {
232
+ var mk = b.ordered ? (startN + i) + '. ' : marker + ' '
233
+ var body = serialize({ blocks: b.items[i].blocks }).replace(/\n$/, '')
234
+ var ls = body.split('\n')
235
+ out.push(mk + ls[0])
236
+ for (var j = 1; j < ls.length; j++) out.push(ls[j] === '' ? '' : ' ' + ls[j])
237
+ }
238
+ return out.join('\n')
239
+ }
240
+ case 'table': {
241
+ var rows = b.rows || []
242
+ var cols = 0
243
+ // 列数取所有行的**最大值** ✓(不截断 ✗ —— 截断会静默丢格子内容 ✗✗)
244
+ // 与渲染器一致:渲染器也是按最宽的那行画 ✓
245
+ // → 若原文件的分隔行列数少于正文行 ✓ 规范化后会补齐 ✓(这是**好**的规范化 ✓)
246
+ for (var r = 0; r < rows.length; r++) if (rows[r].length > cols) cols = rows[r].length
247
+ if (!cols) return ''
248
+ var lines = []
249
+ for (var r2 = 0; r2 < rows.length; r2++) {
250
+ var row = rows[r2].slice()
251
+ while (row.length < cols) row.push('')
252
+ lines.push('| ' + row.join(' | ') + ' |')
253
+ if (r2 === 0) {
254
+ var sep = []
255
+ for (var c = 0; c < cols; c++) {
256
+ var al = (b.align && b.align[c]) || ''
257
+ sep.push(al === 'center' ? ':---:' : (al === 'right' ? '---:' : '---'))
258
+ }
259
+ lines.push('| ' + sep.join(' | ') + ' |')
260
+ }
261
+ }
262
+ var txt = lines.join('\n')
263
+ if (b.widths && b.widths.length) {
264
+ var any = false
265
+ for (var w = 0; w < b.widths.length; w++) if (b.widths[w] > 0) { any = true; break }
266
+ if (any) txt += '\n<!-- ccv-table: ' + b.widths.map(function (x) { return Math.round(x) || 0 }).join(',') + ' -->'
267
+ }
268
+ if (b.heights && b.heights.length) {
269
+ var anyH = false
270
+ for (var hh = 0; hh < b.heights.length; hh++) if (b.heights[hh] > 0) { anyH = true; break }
271
+ if (anyH) txt += '\n<!-- ccv-table-h: ' + b.heights.map(function (x) { return x > 0 ? Math.round(x) : 'auto' }).join(',') + ' -->'
272
+ }
273
+ return txt
274
+ }
275
+ default: return ''
276
+ }
277
+ }
278
+ function repeat(s, n) { var o = ''; for (var i = 0; i < n; i++) o += s; return o }
279
+
280
+ // ─── 幂等自检(P1 的验收工具 ✓)────────────────────────────────
281
+ // 返回 { once, twice, stable } —— stable 为 true 才算过关 ✓
282
+ function roundTrip(md) {
283
+ var once = serialize(parse(md))
284
+ var twice = serialize(parse(once))
285
+ return { once: once, twice: twice, stable: once === twice }
286
+ }
287
+
288
+ var API = { parse: parse, serialize: serialize, roundTrip: roundTrip, splitRow: splitRow }
289
+ try { if (typeof window !== 'undefined') window.__ccvDocModel = API } catch (_) {}
290
+ return API
291
+ })()
package/lib/selref.js CHANGED
@@ -4,8 +4,14 @@
4
4
  // 可点、整块删除、按发送时自动翻译成带定位信息的上下文。
5
5
  // 设计稿:canvas-docs/话布选区引用方案.md(v3)
6
6
  //
7
- // 加载方式:与 docdrop / doclink 完全相同 —— host 端点 /api/canvas/selref.js
8
- // 提供源码,client 入口动态执行。**改本文件只需刷新页面,不用重启。**
7
+ // 加载方式:侧模块 —— host 端点 /api/canvas/selref.js 提供源码,
8
+ // client 入口动态执行。**改本文件只需刷新页面,不用重启。**
9
+ //
10
+ // ⚠️⚠️ **这个文件就是源头,直接改这里**(2026-10-14 补的说明)
11
+ // 与它同类的还有 lib/viewer-md.js(同样是源头)。
12
+ // 与之相反:**lib/client.js 是生成的** ✗(由 src/client/editor-panel.js 复制而来,
13
+ // `npm run build` 会覆盖它)—— 直接改 lib/client.js 会被冲掉 ✗
14
+ // (原注释写"与 docdrop / doclink 相同"——那两个模块 2026-10-11 随画布面板删除了 ✓)
9
15
  //
10
16
  // ── 与主模块的通信(重要)────────────────────────────────────────────
11
17
  // 侧模块由 new Function(src)() 执行,作用域是**全局**,拿不到主模块的局部变量
@@ -421,6 +427,27 @@
421
427
  head = "【话布选区】" + (rec.doc.title || "(未命名)")
422
428
  }
423
429
  var out = head + "\n「" + rec.selected + "」"
430
+ // ── 零宽编号:**已验证可行,但还没启用**(2026-10-04 实测)──────
431
+ // 结论:零宽字符能一路活到服务端,可以拿来给每条引用藏一个看不见的编号。
432
+ //
433
+ // 实测证据(探针已撤,数据留着):
434
+ // 客户端 ZW探针: serialize 输出含零宽空格 3 个,编号 1 / 编号 2
435
+ // 会话 agent/inbox/spliced 含 6 个 → user/message 含 6 个(2 条 × 3)
436
+ // 全链路 0 处被洗掉
437
+ //
438
+ // 为什么需要它:同一篇文档里同一个词出现多次时,两条引用会长得**一模一样**
439
+ // (同一条 @路径 + 同一段「原文」),光靠文字分不出谁是谁,只能靠顺序 ——
440
+ // 而顺序一被调换就会**静默改错地方**。有了看不见的编号,
441
+ // 配对就与顺序无关:删掉一条、调换顺序、只发其中一条,都仍然精确。
442
+ //
443
+ // 启用时的写法(等编辑区方案落地再打开):
444
+ // out = out + "\u200B" + 把 rec.code 编成零宽序列
445
+ // (U+200B 当 0、U+200C 当 1 就够;解码在 host 侧的 agent/pre-step)
446
+ //
447
+ // ⚠️ 教训:上一版探针装进了 client.js 的 insertQuoteBlock —— 那里**不是**
448
+ // 引用正文的产生地(正文是这里、发送那一刻拼的),结果白测一轮。
449
+ // 判断依据是**比对格式**:`@路径\n「原文」` 两行式,只有这里有。
450
+ // ────────────────────────────────────────────────────────
424
451
 
425
452
  // 正文里不再有定位信息。真正的"到底是哪一处"由服务端**主动注入**给模型 ——
426
453
  // 那边读的是**当前**文档,顺带把"这段引用是不是已经失效"也一并告诉它。
@@ -546,28 +573,84 @@
546
573
  return { start: det, end: det, draftRev: d.rev }
547
574
  }
548
575
 
576
+ // ═══ 「插入前先停在行首」═══════════════════════════════════════════
577
+ // 🔴 2026-10-04 主人实测:连着插两条引用,**第二条会粘在前一句话屁股上** ——
578
+ //
579
+ // @"H:/…/123.md"
580
+ // 「1111」 测试1@"H:/…/123.md" ← 粘住了,换行被吃掉
581
+ // 「1111」 测试2
582
+ //
583
+ // 因为胶囊是**零宽追加到草稿末尾**的(见 tailSpan):末尾正好是主人刚打的字时,
584
+ // 它就直接贴上去。所以插入之前要保证草稿停在行首。
585
+ //
586
+ // ⚠️ 为什么不能"补一次换行就完事":补换行**也是一次官方编辑**,它会把 draftRev
587
+ // 推进一格,而 window.__ccvInputState 是**异步**更新的。紧接着拿旧 rev 去派发
588
+ // 插入胶囊,会被官方的版本校验(CAS)拒掉 —— 那正是主人报过两次的
589
+ // 「第二条引用插不进去」。所以补完必须**等 rev 真的变了**再往下走。
590
+ function draftNeedsNewline(d) {
591
+ var t = d ? String(d.text || "") : ""
592
+ return t.length > 0 && !/\n$/.test(t)
593
+ }
594
+
595
+ // 轮询等草稿版本号变化(= 官方投影落地)。**超时也放行** ——
596
+ // 宁可拿旧 rev 试一次(官方会拒,拒了不破坏任何东西),也不卡在这里。
597
+ function waitDraftRevChanged(oldRev, timeoutMs) {
598
+ return new Promise(function (resolve) {
599
+ var t0 = Date.now()
600
+ function tick() {
601
+ var d = draftNow()
602
+ if (d && d.rev !== oldRev) return resolve(d)
603
+ if (Date.now() - t0 >= timeoutMs) {
604
+ log("补换行后等 rev 变化超时(" + timeoutMs + "ms),按当前草稿继续")
605
+ return resolve(d || null)
606
+ }
607
+ setTimeout(tick, 10)
608
+ }
609
+ tick()
610
+ })
611
+ }
612
+
613
+ var NEWLINE_WAIT_MS = 300
614
+
615
+ // @returns {Promise<object|null>} 补完换行之后**新读**的草稿描述(rev 是新的)
616
+ function ensureDraftAtLineStart() {
617
+ var d = draftNow()
618
+ if (!d) return Promise.resolve(null)
619
+ if (!draftNeedsNewline(d)) return Promise.resolve(d) // 空草稿或在行首:不用补
620
+ var ok = dispatchInput("slash/input-insert-text", { text: "\n", span: tailSpan(d) })
621
+ log("插入前补换行:" + String(ok) + "(草稿末尾是 " + JSON.stringify(String(d.text).slice(-8)) + ")")
622
+ if (!ok) return Promise.resolve(draftNow()) // 官方拒了:原样继续,不做别的
623
+ return waitDraftRevChanged(d.rev, NEWLINE_WAIT_MS)
624
+ }
625
+
549
626
  // 直接插入胶囊。成功 = 官方返回 true **且**胶囊数真的多了一个
550
627
  // (官方返回 true 只代表"编辑器接受并应用了这次编辑",仍要数一遍才作数)。
551
628
  function insertChipDirect(rec) {
552
629
  return new Promise(function (resolve) {
553
630
  try {
554
631
  if (!rec) return resolve(false)
555
- var d = draftNow()
556
- if (!d) return resolve(false)
557
- var before = chipCount()
558
- var ok = dispatchInput("slash/input-insert-reference", {
559
- reference: chipPayload(rec),
560
- span: tailSpan(d)
561
- })
562
- log("直接插入:事件返回 " + String(ok) + "(草稿 " + d.text.length + " 字,版本 " + d.rev
563
- + ",已有胶囊 " + before + " 条)")
564
- if (!ok) return resolve(false)
565
- setTimeout(function () {
566
- var after = chipCount()
567
- if (after > before) { log("直接插入成功:胶囊进了输入框(" + before + " → " + after + ")"); return resolve(true) }
568
- log("直接插入:事件说成功了,但胶囊数没变(" + before + " → " + after + ")")
569
- resolve(false)
570
- }, VERIFY_MS)
632
+ ensureDraftAtLineStart().then(function (d) {
633
+ try {
634
+ if (!d) return resolve(false)
635
+ var before = chipCount()
636
+ var ok = dispatchInput("slash/input-insert-reference", {
637
+ reference: chipPayload(rec),
638
+ span: tailSpan(d)
639
+ })
640
+ log("直接插入:事件返回 " + String(ok) + "(草稿 " + d.text.length + " 字,版本 " + d.rev
641
+ + ",已有胶囊 " + before + " 条)")
642
+ if (!ok) return resolve(false)
643
+ setTimeout(function () {
644
+ var after = chipCount()
645
+ if (after > before) { log("直接插入成功:胶囊进了输入框(" + before + " → " + after + ")"); return resolve(true) }
646
+ log("直接插入:事件说成功了,但胶囊数没变(" + before + " → " + after + ")")
647
+ resolve(false)
648
+ }, VERIFY_MS)
649
+ } catch (e) {
650
+ log("直接插入异常: " + (e && e.message))
651
+ resolve(false)
652
+ }
653
+ }, function () { resolve(false) })
571
654
  } catch (e) {
572
655
  log("直接插入异常: " + (e && e.message))
573
656
  resolve(false)
@@ -583,8 +666,12 @@
583
666
  try {
584
667
  var d = draftNow()
585
668
  if (!d) { log("兜底插纯文字:读不到草稿,放弃"); return false }
586
- var ok = dispatchInput("slash/input-insert-text", { text: String(body), span: tailSpan(d) })
587
- log("兜底插纯文字:事件返回 " + String(ok))
669
+ // 纯文字这条路**不需要等**:换行可以和正文一起塞进同一次派发。
670
+ // (胶囊那条路做不到 —— insertReference 一次只能插一颗胶囊,带不了换行,
671
+ // 所以它才要"先派发换行、等 rev 落地、再派发胶囊"两步。)
672
+ var text = (draftNeedsNewline(d) ? "\n" : "") + String(body)
673
+ var ok = dispatchInput("slash/input-insert-text", { text: text, span: tailSpan(d) })
674
+ log("兜底插纯文字:事件返回 " + String(ok) + (text.charAt(0) === "\n" ? "(已补换行)" : ""))
588
675
  return ok
589
676
  } catch (e) {
590
677
  log("兜底插纯文字异常: " + (e && e.message))