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,4075 @@
1
+ // DSH 自带文档查看器里的 .md 渲染器 —— 用「话布文档」接管它
2
+ //
3
+ // ⚠️⚠️ **这个文件就是源头,直接改这里**(2026-10-14 补的说明)
4
+ // 它和 lib/selref.js 一样,是「按请求供源码」的侧模块:
5
+ // 宿主用 GET /api/canvas/viewer-md.js 现读现给(Cache-Control: no-cache)
6
+ // → 改完**刷新页面即生效** ✓ 不用 build、不用重启 ✓
7
+ // 与之相反:**lib/client.js 是生成的** ✗(由 src/client/editor-panel.js 复制而来,
8
+ // `npm run build` 会覆盖它)—— 直接改 lib/client.js 会被冲掉 ✗
9
+ // 三个文件两种规矩,所以在这里写清楚。新增侧模块还要记得改两处:
10
+ // · src/client/editor-panel.js 顶部的加载清单
11
+ // · src/host/11-canvas-extras.js 里的端点白名单(**服务端常量 → 要重启** ✗)
12
+ //
13
+ // ═══ 为什么走这条路(2026-10-04 查证后定的)═══════════════════════
14
+ // ① 「点文档开哪儿」原先靠**抢点击**:doclink 只认 `[data-presented-file]`(present 生成的那种
15
+ // 卡片)。别的会话用 write/Edit 写出来的文档落在「改动文件」那一行,认不到 → 走了自带查看器。
16
+ // 靠抢点击永远抢不全。
17
+ // ② DSH 的查看器**明确允许外部实现顶掉内置实现**。registry.d.ts 原话:
18
+ // "External implementations win over product implementations"
19
+ // 而 `priority: 'extension'` 就是为这个准备的。内置那版仍作为**备选**留着
20
+ // (registry 保留 lower-priority alternatives)—— 所以接管是**可逆的**,用户随时能切回去。
21
+ // ③ 走的是**声明过的公开接口**(带 .d.ts),比我们现在"钩 DOM 属性、猜服务名"稳得多。
22
+ //
23
+ // ═══ 进度 ═══════════════════════════════════════════════════════
24
+ // 第 1 步(已完成):只读接管 —— 内容由框架按 text-pages 交给我们,我们负责渲染。
25
+ // 第 3 步(本版):**划词问AI** —— 在正文里选一段字,浮出「问AI」,点它把引用插进输入框。
26
+ // 复用的是现成那套(lib/selref.js):__ccvSelRefCapture 记一笔 → __ccvSelRefAuto 走官方两步。
27
+ // 我们只做三件事:① 把官方 inputActions / 会话 id / 取数桥挂到全局(跟主模块 InputBridge 同一套)
28
+ // ② 划词浮按钮 ③ 点了之后按顺序喊那两个函数。
29
+ // 编辑、保存、撤销留到下一步。
30
+ //
31
+ // ⚠️⚠️ 两个踩过的坑,别再踩:
32
+ // ① content 是**顶层 prop**,不是 props.owner.content!
33
+ // 内置那版原话:`function MarkdownBody({ content, resourceAddress, useResource, t })`,
34
+ // 紧跟 `if (content.kind !== "text") return null`。
35
+ // 我按想象的形状写 → undefined → 渲染出**空白 div** → 主人的 .md 什么都不显示。
36
+ // ② React 拿不到:本模块是 host 端点供源码后 `new Function(src)()` **裸 eval** 的 ——
37
+ // 既没有 require,也没有 window.React。主模块的 React 来自模块加载器的 require("react")。
38
+ // 所以主模块把 React / h 借到 window.__ccvKit 上,这里从那儿取。
39
+ // 两个坑的表现**一模一样(都是空白)**,全靠诊断日志打出 `React=没有` 才分清 ——
40
+ // 所以:**宁可显示"拿不到内容/出错",也绝不静默空白**。
41
+ ;
42
+ (function () {
43
+ var VERSION = 2
44
+ // ⚠️ id 必须全局唯一:重名时 documentPreviews.register 会**抛错**
45
+ var ID = 'collab-canvas-md'
46
+ // ⚠️ 显示名是「话布文档」而不是「话布」(2026-10-11 主人定):
47
+ // 查看器右上角那个下拉框列的是"用哪个渲染器"(官方还有 Markdown / 代码 / 纯文本 ✓),
48
+ // 而"话布"将来是**一整套功能** —— markdown 只是第一档 ✗。
49
+ // 主人原话:以后会有 UI 实时可视编辑网页等,所以需要一个**话布标签页**,
50
+ // 打开后**选功能**;标签页那件事现在先不做(说明留在 editor-panel.js 里 ✓)。
51
+ // 所以这里先把自己叫准:我是"话布·文档"这一档 ✓ 不是"话布"全体 ✓
52
+ var TITLE = '话布文档'
53
+ var booted = false
54
+
55
+ // ⚠️ 日志限流(2026-10-17 事故的防御 ✓)
56
+ // 事故:一个"每 400ms 自我重排"的循环 ✗ → 日志刷出成百上千行 ✓
57
+ // 而**每一行日志都是一次 POST** ✗(→ /api/canvas/log ✓)
58
+ // → 浏览器与服务端被淹 ✓ → 页面崩掉刷新 ✗ → 拼音里没上屏的内容全丢 ✓✓
59
+ // 所以:**同一句话 1 秒内只发一次** ✓ 被压掉的次数补在后面 ✓
60
+ // (日志是排查用的 ✓ 但不能成为"把系统压垮"的通道 ✗)
61
+ var logSeen = {}
62
+ var logDrop = {}
63
+ function log(m) {
64
+ try {
65
+ var now = Date.now()
66
+ var key = String(m).slice(0, 60)
67
+ if (logSeen[key] && (now - logSeen[key]) < 1000) {
68
+ logDrop[key] = (logDrop[key] || 0) + 1
69
+ return
70
+ }
71
+ var extra = ''
72
+ if (logDrop[key]) { extra = '(同句被压掉 ' + logDrop[key] + ' 次)'; logDrop[key] = 0 }
73
+ logSeen[key] = now
74
+ if (window.__ccvKit && window.__ccvKit.log) window.__ccvKit.log(m + extra, 'viewer')
75
+ } catch (_) {}
76
+ }
77
+
78
+ // markdown → HTML:复用主模块的 mdToHtml(挂在 __ccvKit 上)。
79
+ // 拿不到就退回**纯文本**(转义后 pre 包起来)—— 宁可朴素,也不能因为渲染器缺失白屏。
80
+ //
81
+ // ★★ A4(2026-10-15):**按块渲染**,不再整篇交给它 ★★
82
+ // 病根(日志实测):主模块的 mdToHtml 是**按行循环**的 ✗
83
+ // var lines = src2.split("\n"); while (i < lines.length) { … out.push("<p>" + … ) }
84
+ // → **多行段落被拆成多个 <p>** ✗✗
85
+ // (日志:往返:有差异 6333 字,行数 308→311,差 100 行 ✓
86
+ // 第208行 原="> 想指回更早那个编辑区就做不到。引用本身就在输入框里摆着," 回="> " ✓)
87
+ // 现在:先用**文档模型**切块 ✓(P1 的成果 ✓)每块单独渲染 ✓ 再拼起来 ✓
88
+ // · 多行段落 / 多行标题 → **合成一个块** ✓ 行间用 <br> ✓
89
+ // · 其余块 → 交给主模块渲染这一块 ✓
90
+ // → 块边界由**模型**决定 ✓ 不再由渲染器"重新推"✗
91
+ function md2html(md) {
92
+ var k = (window.__ccvKit && typeof window.__ccvKit.mdToHtml === 'function') ? window.__ccvKit : null
93
+ if (k) {
94
+ var M = window.__ccvDocModel
95
+ if (M && typeof M.parse === 'function' && typeof M.serialize === 'function') {
96
+ try {
97
+ var doc = M.parse(md)
98
+ var parts = []
99
+ for (var i = 0; i < doc.blocks.length; i++) {
100
+ var b = doc.blocks[i]
101
+ // 多行段落 / 多行标题 → 一个块 ✓(行间 <br> ✓ 保留你写的换行 ✓)
102
+ if ((b.type === 'p' || b.type === 'h') && String(b.text).indexOf('\n') >= 0) {
103
+ var ls = String(b.text).split('\n')
104
+ var inner = ls.map(function (one) {
105
+ var head = ''
106
+ if (b.type === 'h') { head = ''; for (var n = 0; n < b.level; n++) head += '#' }
107
+ var h = k.mdToHtml(b.type === 'h' ? (head + ' ' + one) : one)
108
+ return String(h)
109
+ .replace(/^\s*<p[^>]*>/, '').replace(/<\/p>\s*$/, '')
110
+ .replace(/^\s*<h[1-6][^>]*>/, '').replace(/<\/h[1-6]>\s*$/, '')
111
+ }).join('<br>')
112
+ parts.push(b.type === 'h'
113
+ ? ('<h' + b.level + '>' + inner + '</h' + b.level + '>')
114
+ : ('<p>' + inner + '</p>'))
115
+ continue
116
+ }
117
+ parts.push(k.mdToHtml(M.serialize({ blocks: [b] })))
118
+ }
119
+ var joined = parts.join('')
120
+ if (joined) return joined
121
+ } catch (err) { log('按块渲染失败,退回整篇渲染: ' + (err && err.message)) }
122
+ }
123
+ try {
124
+ var out = k.mdToHtml(md)
125
+ if (typeof out === 'string' && out) return out
126
+ } catch (_) {}
127
+ }
128
+ return '<pre style="white-space:pre-wrap;word-break:break-word">'
129
+ + String(md == null ? '' : md).replace(/[&<>]/g, function (c) {
130
+ return c === '&' ? '&amp;' : (c === '<' ? '&lt;' : '&gt;')
131
+ }) + '</pre>'
132
+ }
133
+
134
+ // ★★ A4 保存侧:**按顶层子元素分块**转 markdown ★★
135
+ // 病根:整棵树一次交给 htmlToMd ✗ → 块边界靠它重新推 ✗
136
+ // → 段落被合并/拆分、列表套代码块漂移 ✓
137
+ // 现在:编辑区每个**顶层子元素**单独转 ✓ 用空行拼 ✓
138
+ // → 块边界 = **DOM 的顶层结构** ✓(就是你看到的块 ✓)
139
+ // ⚠️ 任何异常/空结果 → 一律退回整棵树转 ✓(宁可老行为,也不能丢内容 ✗)
140
+ function editorToMd(el) {
141
+ var k = window.__ccvKit
142
+ if (!el) return ''
143
+ if (!k || typeof k.htmlToMd !== 'function') return ''
144
+ try {
145
+ var kids = Array.prototype.slice.call(el.childNodes || [])
146
+ var blocks = [], seen = 0
147
+ for (var i = 0; i < kids.length; i++) {
148
+ var n = kids[i]
149
+ if (n.nodeType === 3) { // 裸文本节点(打字时浏览器会冒出来 ✓)
150
+ var tx = String(n.nodeValue || '').replace(/\u00a0/g, ' ').trim()
151
+ if (tx) { blocks.push(tx); seen++ }
152
+ continue
153
+ }
154
+ if (n.nodeType !== 1) continue
155
+ var one = k.htmlToMd(n)
156
+ if (typeof one === 'string' && one.replace(/\s+/g, '') !== '') {
157
+ blocks.push(one.replace(/^\n+/, '').replace(/\n+$/, ''))
158
+ seen++
159
+ }
160
+ }
161
+ if (!seen) return k.htmlToMd(el) // 兜底 ✓
162
+ var md = blocks.join('\n\n')
163
+ if (!md.replace(/\s+/g, '')) return k.htmlToMd(el) // 结果空 → 兜底 ✓
164
+ return md
165
+ } catch (err) {
166
+ log('按块序列化失败,退回整棵: ' + (err && err.message))
167
+ try { return k.htmlToMd(el) } catch (_) { return '' }
168
+ }
169
+ }
170
+
171
+ // ─── React(从主模块借,见文件头 ⚠️②)────────────────────────
172
+ var R = null
173
+ function ensureR() {
174
+ if (R) return R
175
+ try {
176
+ var k = window.__ccvKit
177
+ if (k && k.React && k.React.createElement) { R = k.React; return R }
178
+ } catch (_) {}
179
+ if (window.React && window.React.createElement) R = window.React
180
+ return R
181
+ }
182
+
183
+ // ─── 往返自检(2026-10-11 加)───────────────────────────────
184
+ // 为什么放在查看器里:自检本来挂在**话布面板**的加载流程,而主人在查看器里翻文档 ——
185
+ // 一次都没跑 ✗。测量点要跟着人走。
186
+ // 往返(md → html → md)本来是**面板保存**时的事,但拼接(实施步骤 3)会让整篇重渲染,
187
+ // 那时漂移就落到主文档上了 —— 所以先量清楚再上拼接。
188
+ // 只跑一次(按 路径 + 字数 记),不刷屏。
189
+ var rtChecked = {}
190
+ function roundTripCheck(md, absPath) {
191
+ try {
192
+ var k = window.__ccvKit
193
+ if (!k || typeof k.htmlToMd !== 'function' || typeof k.mdToHtml !== 'function') {
194
+ log('往返自检:主模块还没把 mdToHtml/htmlToMd 借出来,跳过')
195
+ return
196
+ }
197
+ var text = String(md || '')
198
+ var key = String(absPath || '?') + '#' + text.length
199
+ if (rtChecked[key]) return
200
+ rtChecked[key] = true
201
+
202
+ var box = document.createElement('div')
203
+ box.innerHTML = k.mdToHtml(text)
204
+ var back = k.htmlToMd(box)
205
+ if (back === null || back === undefined) { log('往返自检:htmlToMd 返回空,跳过'); return }
206
+ if (back === text) { log('往返:无损 ✓ ' + text.length + ' 字'); return }
207
+
208
+ var la = text.split('\n')
209
+ var lb = String(back).split('\n')
210
+ var nd = 0
211
+ var diffs = []
212
+ for (var i = 0; i < Math.max(la.length, lb.length); i++) {
213
+ if (la[i] === lb[i]) continue
214
+ nd++
215
+ if (diffs.length < 3) {
216
+ diffs.push('第' + (i + 1) + '行 原=' + JSON.stringify(String(la[i] === undefined ? '(无此行)' : la[i]).slice(0, 44))
217
+ + ' 回=' + JSON.stringify(String(lb[i] === undefined ? '(无此行)' : lb[i]).slice(0, 44)))
218
+ }
219
+ }
220
+ log('往返:有差异 ' + text.length + ' 字,行数 ' + la.length + '→' + lb.length
221
+ + ',差 ' + nd + ' 行 | ' + diffs.join(' | '))
222
+ } catch (e) { log('往返自检出错:' + (e && e.message)) }
223
+ }
224
+
225
+ // ─── 查看器工具栏按钮(撤销 / 重做)────────────────────────
226
+ // 挂到 `sidebar.right.tab.document.actions`(**list** 槽)—— 注册字段是 id/order/label ✓
227
+ // owner props 是 `{ absolutePath }` ✓
228
+ // 契约来源两处(都对上了才敢写):
229
+ // · contract.d.ts: owner: { readonly absolutePath: string }
230
+ // · 官方 open-in-app 的写法:renderSlot(name, { absolutePath })
231
+ // 这个槽的语义是"作用于当前预览文件的工具栏按钮" ✓ 正好放撤销/重做。
232
+ //
233
+ // ⚠️ 暂时**不放"保存"**:查看器这版还是**只读**的 ✗ 没有本地改动可存 ——
234
+ // 保存要等编辑(contentEditable)做进来,否则是个点了没意义的按钮 ✗
235
+ function canvasIdForPath(p) {
236
+ if (!p || !canvasMap) return ''
237
+ var hit = canvasMap[normPath(p)]
238
+ return hit ? hit.id : ''
239
+ }
240
+
241
+ function Actions(props) {
242
+ var r = ensureR()
243
+ if (!r) return null
244
+ return ActionsInner(props, r)
245
+ }
246
+
247
+ function ActionsInner(props, r) {
248
+ var h = r.createElement
249
+ var absPath = (props && props.absolutePath) || ''
250
+ var st = r.useState({ id: '', canUndo: false, canRedo: false })
251
+ var info = st[0]
252
+ var setInfo = st[1]
253
+ // 编辑状态(正文组件那边写进来的):dirty = 有没有未保存的改动
254
+ var ds = useDocStore(absPath, r)
255
+ var dirty = !!(ds && ds.dirty)
256
+
257
+ // 挂载 / 换文件时读一次状态(canUndo / canRedo / 版本号 / 原文 / 本地草稿)
258
+ //
259
+ // 🔴🔴 **必须有依赖数组**(2026-10-15 修,主人报的三个症状都是它引起的 ✗)
260
+ // 原来写成 useEffect(fn) —— 没有第二个参数 ✗ → **每次渲染都跑** ✓
261
+ // 而它里面会 docPatch → docNotify → 触发重渲染 → 又跑 ✗ → **死循环** ✓
262
+ // 后果(主人现场):
263
+ // · 焦点被反复抢走 → 操作一次后焦点从话布跳到聊天框 ✗
264
+ // · 连按 Ctrl+Z 不行(第二次的按键已经不在话布上了 ✗)
265
+ // · 循环里每次都去查本地草稿 → 撤销后又被"恢复草稿"盖回来 ✗
266
+ r.useEffect(function () {
267
+ // ─── 读一篇的状态(canUndo/canRedo/版本/原文/本地草稿)──────────────
268
+ // 抽成函数是为了"映射表载入后重试" ✓(见下面那段注释 ✓)
269
+ var readState = function (id) {
270
+ try {
271
+ fetch('/api/canvas/read?id=' + encodeURIComponent(id) + '&' + ccvSidQ(), { cache: 'no-store' })
272
+ .then(function (x) { return x.json() })
273
+ .then(function (d) {
274
+ setInfo({ id: id, canUndo: !!d.canUndo, canRedo: !!d.canRedo })
275
+ // ⚠️ 同时写进 docStore ✓ —— 快捷键(Ctrl+Z/Y)判断"画布有没有可撤销/可重做的"
276
+ // 读的就是这里 ✗ 只放进组件 state 的话,快捷键永远读到 undefined ✗
277
+ docPatch(absPath, { canUndo: !!d.canUndo, canRedo: !!d.canRedo })
278
+ var ver = (typeof d.version === 'number') ? d.version : 0
279
+ var srv = (typeof d.content === 'string') ? d.content : ''
280
+ // 建立基准:服务端原文 + 版本号(保存时当 knownVer ✓)
281
+ docPatch(absPath, { id: id, ver: ver, saved: srv, md: srv, dirty: false })
282
+ // ── 恢复本地草稿(防丢 ✓ 与面板同一套判据)──────────────────
283
+ // 面板的规矩:草稿的**基准版本对得上**才恢复 ✓ 对不上就不写回、只提示 ✓
284
+ // 另加一道"每篇只试一次"的闸(重挂载时不会再翻旧账 ✗)
285
+ if (draftTried[id]) { log('草稿检查已做过(' + id + '),跳过'); return }
286
+ draftTried[id] = true
287
+ var dft = null
288
+ try { if (window.__ccvKit && window.__ccvKit.draftGet) dft = window.__ccvKit.draftGet(id) } catch (_) {}
289
+ if (dft && typeof dft.content === 'string' && dft.content !== srv) {
290
+ if (typeof dft.base === 'number' && dft.base === ver) {
291
+ log('恢复未保存草稿:' + dft.content.length + ' 字(服务端 ' + srv.length + ' 字)')
292
+ docPatch(absPath, { override: dft.content, md: dft.content, dirty: true })
293
+ toastOn('恢复了未保存的草稿 —— 点保存写回')
294
+ } else {
295
+ log('草稿基准不符(草稿 v' + dft.base + ' / 服务端 v' + ver + '),不自动恢复')
296
+ }
297
+ }
298
+ })
299
+ .catch(function () { setInfo({ id: id, canUndo: false, canRedo: false }) })
300
+ } catch (_) {}
301
+ }
302
+ // ─── 拿到 id 再读;拿不到就等映射表载入后**重试** ────────────────
303
+ // ★ 2026-10-15 修「这份文件不是话布,保存不了」★
304
+ // 日志证据:路径日志 23:45:55.866 ✓ 而「清单已载入 14 篇」是 23:45:56.028
305
+ // —— **晚了 162ms** ✗
306
+ // 原来这里 `if (!id) { setInfo({id:''}); return }` 直接返回 ✗
307
+ // → 之后**再也没人重试** ✗✗ → 保存时 d.id 为空 → 弹「不是话布」✓
308
+ // (右侧栏之所以"以前好使",是因为它开得久、映射表早就在缓存里了 ✓
309
+ // 而独立标签页每次都是新挂载 → 必然撞上这个竞态 ✗)
310
+ var id = canvasIdForPath(absPath)
311
+ if (id) { readState(id); return }
312
+ log('挂载时映射表里还没有这篇 → 等它载入后重试 ✓')
313
+ loadCanvasMap(function () {
314
+ var id2 = canvasIdForPath(absPath)
315
+ log('映射表载入后重试:' + (id2 ? '拿到 ' + id2 + ' ✓' : '还是没有 ✗(这篇可能不在清单里)'))
316
+ if (id2) readState(id2)
317
+ else setInfo({ id: '', canUndo: false, canRedo: false })
318
+ })
319
+ }, [absPath])
320
+
321
+ // ── 保存 ──────────────────────────────────────────────
322
+ // 自动保存见 afterEdit/saveDoc(打字停手 + 失焦都会存 ✓ 成熟文档软件的做法)。
323
+ // 这个按钮是**立即保存**:不想等那 1.5 秒时点它 ✓ 状态也在它身上显示 ✓
324
+ var doSave = function () { saveDoc(absPath, { manual: true }) }
325
+
326
+ var doIt = function (kind) {
327
+ // ★ 查找按钮(2026-10-17 D1):不走服务端 ✓ 只开浮层 ✓ 不需要话布 id ✓
328
+ // 为什么必须有它:主人试 Ctrl+F 时弹出来的是**浏览器的搜索** ✗
329
+ // (它连对话一起搜 ✓ 主人现场:文档 4 个 + 对话 4 个 = 8 ✓)
330
+ // 按钮是**永远点得到**的入口 ✓ 不依赖焦点在哪 ✓
331
+ if (kind === 'find') { findShow(absPath); return }
332
+ var id = info.id || canvasIdForPath(absPath)
333
+ var toast = function (m) {
334
+ try { if (window.__ccvKit && window.__ccvKit.toast) window.__ccvKit.toast(m, 'ccv-viewer-undo') } catch (_) {}
335
+ }
336
+ if (!id) { toast('这份文件不是话布,撤销用不了'); return }
337
+ try {
338
+ fetch('/api/canvas/' + kind, {
339
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
340
+ body: JSON.stringify({ id: id }),
341
+ }).then(function (x) { return x.json() })
342
+ .then(function (d) {
343
+ log(kind + ' → ' + JSON.stringify(d))
344
+ if (!d || !d.ok) { toast(d && d.error ? ('失败:' + d.error) : '服务端没响应'); return }
345
+ setInfo({ id: id, canUndo: !!d.canUndo, canRedo: !!d.canRedo })
346
+ // ⚠️⚠️ 2026-10-29 主人:「撤销永远深色,重做永远是灰色」
347
+ // 真因:上面这行只写了**组件 state**(info)✗
348
+ // 两个按钮读的却是 **store**(ds = useDocStore)✗
349
+ // → store 里的 canRedo 从来没被更新 → 重做永远灰 ✓
350
+ // → 撤销那边因为 dirty 一直为真 → 永远深 ✓
351
+ // 修法:状态**同时写进 store** ✓ 按钮订阅 store → 到点就变 ✓
352
+ docPatch(absPath, { canUndo: !!d.canUndo, canRedo: !!d.canRedo })
353
+ // ★ 当场显示结果,**不等文件监听**(2026-10-11 修"撤销有延迟"):
354
+ // host 那边已经改成立刻落盘 ✓,但"文件变了 → 查看器重读"这一段仍有延迟 ✗
355
+ // 而响应里本来就带 content ✓ → 直接拿它渲染 ✓ 手感就是即时的 ✓
356
+ // override 会在 props.content 追上来之后自动丢掉(见 BodyInner 的 effect ✓)
357
+ if (typeof d.content === 'string') {
358
+ docPatch(absPath, { override: d.content, saved: d.content, md: d.content, dirty: false })
359
+ }
360
+ })
361
+ .catch(function (e) { toast('失败:' + (e && e.message)); })
362
+ } catch (e) { toast('失败:' + (e && e.message)); }
363
+ }
364
+
365
+ // ── 图标按钮的样式(一次性注入)──────────────────────────
366
+ // 只用主题 token ✓(技能要求;token 名字是查 Theme.listTokens 拿的,不是猜的 ✓)
367
+ // --dsw-alias-label-secondary 常态
368
+ // --dsw-alias-label-primary 悬停
369
+ // --dsw-alias-bg-layer-2 悬停底色(没有 bg-hover 这个 token ✗)
370
+ // --dsw-alias-state-idle-primary **没得撤销时的颜色**(它的用途就是"inactive"✓)
371
+ // 不 import Harness 客户端包 ✓(技能明确禁止)→ 图标是自带的内联 SVG ✓
372
+ function ensureActionsCss() {
373
+ try {
374
+ if (document.getElementById('ccv-viewer-actions-style')) return
375
+ var el = document.createElement('style')
376
+ el.id = 'ccv-viewer-actions-style'
377
+ el.textContent =
378
+ '.ccv-vtool{width:26px;height:26px;display:inline-flex;align-items:center;justify-content:center;'
379
+ + 'padding:0;border:0;border-radius:6px;background:transparent;cursor:pointer;'
380
+ + 'color:var(--dsw-alias-label-secondary,#666);transition:background .12s,color .12s}'
381
+ + '.ccv-vtool:hover{background:var(--dsw-alias-bg-layer-2,rgba(128,128,128,.14));'
382
+ + 'color:var(--dsw-alias-label-primary,#1a1a1a)}'
383
+ + '.ccv-vtool[data-off="1"]{color:var(--dsw-alias-state-idle-primary,rgba(128,128,128,.55))}'
384
+ + '.ccv-vtool:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,#4c8dff);outline-offset:1px}'
385
+ // 正在写盘:用品牌色 + 半透明("保存中…"要看得出来 ✓ 成熟软件都会显示状态)
386
+ + '.ccv-vtool[data-saving="1"]{color:var(--dsw-alias-brand-primary,#4c8dff);opacity:.7}'
387
+ // ── 挪位置:放到「话布」两个字**左边**(2026-10-11 主人指定)──────
388
+ // 那个槽按设计是渲染在内置控件**之后** ✗("rendered after the preview's own
389
+ // controls"),所以只能用 CSS 的 order 挪。查过宿主 CSS:
390
+ // .dhJKeW_header{ align-items:center; gap:4px; height:38px; display:flex }
391
+ // → 是 flex ✓ order 生效 ✓
392
+ // 两级 order 才能精确插到"路径标签"和"话布"之间:
393
+ // 路径标签 -2 → 我们 -1 → 其余(话布、⟳ ⤢ ↗ ⛶)默认 0
394
+ // :has() 那一条是防"槽宿主还套了一层"—— 套了就连宿主一起提前 ✓
395
+ // (order 在非 flex 容器里是空操作 ✓ 所以套错地方也不会坏 ✗)
396
+ + '[data-ccv-viewer-actions]{order:-1}'
397
+ + '*:has(>[data-ccv-viewer-actions]){order:-1}'
398
+ + '[data-textpreview-path]{order:-2}'
399
+ document.head.appendChild(el)
400
+ } catch (_) {}
401
+ }
402
+
403
+ // 内联 SVG 图标(自带 ✓ 不依赖任何图标库 ✓)—— 弧形箭头,与内置图标同一视觉重量
404
+ function svgIcon(d) {
405
+ return h('svg', {
406
+ width: 14, height: 14, viewBox: '0 0 16 16', fill: 'none',
407
+ stroke: 'currentColor', strokeWidth: 1.5,
408
+ strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': 'true',
409
+ }, h('path', { d: d }))
410
+ }
411
+ var ICON_UNDO = 'M3.2 7.6h6.3a3.1 3.1 0 0 1 0 6.2H7.4 M6 4.4 3 7.6l3 3.2'
412
+ var ICON_REDO = 'M12.8 7.6H6.5a3.1 3.1 0 0 0 0 6.2h2.1 M10 4.4l3 3.2-3 3.2'
413
+ // 保存:下箭头进托盘(和内置图标的重量一致 ✓ 自带 SVG 不依赖图标库 ✓)
414
+ var ICON_SAVE = 'M8 2.4v7.2 M4.9 6.6 8 9.7l3.1-3.1 M2.9 11.2v1.4a1 1 0 0 0 1 1h8.2a1 1 0 0 0 1-1v-1.4'
415
+ // 查找:放大镜 ✓(2026-10-17 D1)
416
+ var ICON_FIND = 'M7.2 2.6a4.6 4.6 0 1 1 0 9.2 4.6 4.6 0 0 1 0-9.2Z M10.6 10.6l3 3'
417
+
418
+ function toolBtn(kind, icon, on, title) {
419
+ return h('button', {
420
+ type: 'button', className: 'ccv-vtool', title: title,
421
+ 'data-ccv-viewer-action': kind, 'data-off': on ? '0' : '1',
422
+ 'aria-label': title,
423
+ onClick: function () { doIt(kind) },
424
+ }, svgIcon(icon))
425
+ }
426
+
427
+ return h('span', {
428
+ 'data-ccv-viewer-actions': '1',
429
+ style: { display: 'inline-flex', gap: '2px', alignItems: 'center' },
430
+ ref: function () { ensureActionsCss() },
431
+ },
432
+ // ⚠️ 撤销/重做的可用状态读 **store**(ds)而不是组件 state(info)✗
433
+ // 2026-10-15 主人报「撤销有用,但是撤销键不变」:
434
+ // 快捷键撤销走的是 docPatch(写 store ✓)而按钮读的是 info(组件 state ✗)
435
+ // → 按钮永远停在挂载时那一次的值 ✗
436
+ // ⚠️ 2026-10-29 主人澄清:「我说的不能重做是说重做到头了,已经不能继续重做,
437
+ // 这时候就应该变灰,撤回同理」
438
+ // → 要的是**准确的禁用态** ✗ 不是永远深色 ✓
439
+ // 状态来源(服务端每次 read / write / undo / redo 的响应都带 canUndo/canRedo):
440
+ // 撤销:canUndo(服务端历史栈还有)或 dirty(刚改过、还能撤掉这次改动)
441
+ // 重做:canRedo(只有撤销过才有)
442
+ // 两者都通过 docPatch 写进 store ✓ 按钮订阅 store → 到点就灰 ✓
443
+ toolBtn('undo', ICON_UNDO, !!(ds && (ds.canUndo || ds.dirty)), '撤销上一步(含 AI 改的)· Ctrl+Z'),
444
+ // 重做:只有 canRedo 为真才深色 ✓(重做到头 → 服务端回 canRedo:false → 立刻灰 ✓)
445
+ toolBtn('redo', ICON_REDO, !!(ds && ds.canRedo), '重做 · Ctrl+Y 或 Ctrl+Shift+Z'),
446
+ // ★ 查找(2026-10-17 D1)★
447
+ // 为什么必须有这个按钮:主人试 Ctrl+F 时**弹出来的是浏览器的搜索** ✗
448
+ // —— 因为我的 Ctrl+F 只挂在编辑区的 keydown 上 ✓
449
+ // 焦点不在编辑区里就不响应 ✓ 浏览器就接管了 ✓
450
+ // → 按钮是**永远点得到**的入口 ✓(不依赖焦点 ✓)
451
+ // 另外说清楚:我们自己的查找**只搜这篇话布** ✓
452
+ // 不像浏览器那个会连对话一起搜 ✓(主人现场:文档 4 个 + 对话 4 个 = 8 ✓)
453
+ toolBtn('find', ICON_FIND, true, '查找 / 替换(只搜这篇话布,不会搜到对话)· Ctrl+F'),
454
+ // ★ 目录(2026-10-24 主人要求「左目录 + 右正文」)★
455
+ // 按标题扫出来 ✓ 只读 ✓ 点一条跳过去 ✓
456
+ // ⚠️ 状态放模块级 navOpen:宿主每次保存都重建组件 ✗ 组件 state 一保存就关 ✓
457
+ // ⚠️ 2026-10-28 主人:「目录按键应该一直深色」
458
+ // 它不像撤销/重做那样有"可用/不可用"✗ 目录**永远能用** ✓
459
+ // 所以 data-off 固定 '0'(深色)✗ 不再跟着 navOpen 变 ✓
460
+ h('button', {
461
+ type: 'button',
462
+ className: 'ccv-vtool',
463
+ title: '目录(按标题快速跳转 · 只读,不改正文)',
464
+ 'data-ccv-viewer-action': 'toc',
465
+ 'data-off': '0',
466
+ 'aria-label': '目录',
467
+ onClick: function () { try { navToggle() } catch (e) { log('目录切换失败: ' + (e && e.message)) } },
468
+ }, h('span', { style: { fontSize: '13px', lineHeight: 1 } }, '☰')),
469
+ // 保存放最后(离"话布"最近 ✓ 三个按钮一组)
470
+ // 状态显示在它身上(成熟软件都让保存状态可见 ✓):
471
+ // 有改动 → 亮 ✓ 没改动 → idle 灰 ✓ 正在写 → title 说"保存中…" ✓
472
+ h('button', {
473
+ type: 'button', className: 'ccv-vtool',
474
+ title: (ds && ds.saving) ? '保存中…' : (dirty ? '立即保存(有未保存的改动;停手 1.5 秒也会自动存)' : '已保存(当前没有改动)'),
475
+ 'data-ccv-viewer-action': 'save', 'data-off': dirty ? '0' : '1',
476
+ 'data-saving': (ds && ds.saving) ? '1' : '0',
477
+ 'aria-label': (ds && ds.saving) ? '保存中' : '保存',
478
+ onClick: function () { doSave() },
479
+ }, svgIcon(ICON_SAVE)))
480
+ }
481
+
482
+ // ─── 样式:跟话布面板共用同一份 markdown 规则 ────────────────
483
+ // 2026-10-04 主人拿两张截图问"为什么两边显示不一样":
484
+ // 两处用的是**同一个 mdToHtml**(都从 __ccvKit 取),差别只在**样式** ——
485
+ // 面板的规则作用域是 `.ccv-panel .ccv-editor`,查看器这边没有 → 同一份 <table>
486
+ // 有边框就是表格,没边框就看起来像纯文本(引用块的左边线同理)。
487
+ // 做法:主模块把那份 CSS 文本借出来(mdCssFor 换掉作用域前缀),这里注入一次。
488
+ // 一份规则两处用,不复制、不会漂移。
489
+ function ensureViewerCss() {
490
+ try {
491
+ if (document.getElementById('ccv-viewer-md-style')) return
492
+ var css = ''
493
+ // ⚠️ 作用域写成**重复两遍的属性选择器**([data-ccv-md-body][data-ccv-md-body]):
494
+ // 这样每一条规则的特异度都抬高一档,应用自带样式(各种 .xxx p{margin:1em})
495
+ // 压不过它。2026-10-04 主人报"表格没框、段落空隙大"—— 症状与"这份样式没生效"
496
+ // 完全一致(默认 p 边距 1em、表格无边框),所以先把特异度钉死,排除被覆盖这条路。
497
+ // 排版参数本身与面板一致(fontSize 14 / lineHeight 1.75,见 CCV_STYLE.editorWide)。
498
+ if (window.__ccvKit && typeof window.__ccvKit.mdCssFor === 'function') {
499
+ css = window.__ccvKit.mdCssFor('[data-ccv-md-body][data-ccv-md-body]')
500
+ }
501
+ if (!css) { log('没拿到 markdown 样式(面板的规则还没就绪?),正文会是素样式'); return }
502
+ var el = document.createElement('style')
503
+ el.id = 'ccv-viewer-md-style'
504
+ el.textContent = css
505
+ document.head.appendChild(el)
506
+ log('已注入查看器 markdown 样式(' + css.length + ' 字节,特异度已抬高)')
507
+ } catch (e) { log('注入样式失败: ' + (e && e.message)) }
508
+ }
509
+
510
+ // ─── 话布清单:文件路径 → 话布 id/标题 ──────────────────────
511
+ // 为什么要它:选区引用(selref 的 capture)要求 doc.id **非空**,否则整笔不记;
512
+ // 而查看器手里只有文件路径(resourceAddress → absolutePath),得换回话布 id。
513
+ var canvasMap = null
514
+ var canvasMapAt = 0
515
+ function normPath(p) { return String(p || '').replace(/\\/g, '/').toLowerCase() }
516
+ function baseName(p) { var s = String(p || '').replace(/\\/g, '/'); return s.slice(s.lastIndexOf('/') + 1) }
517
+
518
+ function loadCanvasMap(cb) {
519
+ if (canvasMap && Date.now() - canvasMapAt < 60000) { if (cb) cb(); return }
520
+ try {
521
+ fetch('/api/canvas/list?scope=all', { cache: 'no-store' })
522
+ .then(function (r) { return r.json() })
523
+ .then(function (d) {
524
+ var m = {}
525
+ var list = (d && d.canvases) || []
526
+ for (var i = 0; i < list.length; i++) {
527
+ var c = list[i]
528
+ if (c && c.path) m[normPath(c.path)] = { id: c.id, title: c.title, version: c.version }
529
+ }
530
+ canvasMap = m
531
+ canvasMapAt = Date.now()
532
+ log('清单已载入 ' + Object.keys(m).length + ' 篇(划词问AI 靠它认话布)')
533
+ if (cb) cb()
534
+ })
535
+ .catch(function (e) { log('清单载入失败: ' + (e && e.message)) })
536
+ } catch (e) { log('清单载入异常: ' + (e && e.message)) }
537
+ }
538
+
539
+ // ─── 输入桥:把官方 inputActions / 会话 id / 取数桥挂到全局 ──
540
+ // 与主模块的 InputBridge 用**同一套全局量** —— lib/selref.js 就是认这些的。
541
+ // sessions.scope(sessionId) → 会话作用域 → inputTriggers.sessionOf(它) → 输入管线控制器
542
+ //
543
+ // ★ ccvSidQ —— 请求里带的会话查询串(2026-10-24 修)★
544
+ // 事故(主人现场):「打开的文档都跑到旧记录.default 里去了,打开一个进去一个」
545
+ // 根因:查看器这两处 `/api/canvas/read?id=…` **根本没带 sid** ✗
546
+ // → 主机端 `sessSafe(url.searchParams.get('sid'))` 拿到 null → 退成 'default' ✗
547
+ // → 每打开一篇就登记到 default 组 ✓(而且和 12 篇老文档混在一起 ✓)
548
+ // 现在:优先用**真 sessionId**(publishInput 早就写进 window.__ccvSessionId ✓
549
+ // 它是 props.sessionId,打开文档那一刻就有 ✓ 比 2 秒轮询的 __ccvSid 更及时 ✓
550
+ // ),拿不到再退回 __ccvSid ✓,最后才 'default' ✓
551
+ function ccvSidQ() {
552
+ try {
553
+ var real = window.__ccvSessionId
554
+ if (real && typeof real === 'string' && /^session-/.test(real)) {
555
+ return 'sid=' + encodeURIComponent(real)
556
+ }
557
+ var k = window.__ccvSid
558
+ if (k) return 'sid=' + encodeURIComponent(k)
559
+ } catch (_) {}
560
+ return 'sid=default'
561
+ }
562
+
563
+ function publishInput(props, absPath, inputState) {
564
+ try { if (absPath) lastAbsPath = absPath } catch (_) {}
565
+ try { if (props.inputActions) window.__ccvInputActions = props.inputActions } catch (_) {}
566
+ try { if (props.sessionId) window.__ccvSessionId = props.sessionId } catch (_) {}
567
+ try { if (inputState) window.__ccvInputState = inputState } catch (_) {}
568
+ // 取数桥:capture 从这里取三样 —— doc / editor / range
569
+ try {
570
+ window.__ccvSelRefBridge = {
571
+ doc: function () {
572
+ var hit = canvasMap ? canvasMap[normPath(absPath)] : null
573
+ return {
574
+ id: hit ? hit.id : '', // ⚠️ 空 id 会让 capture 整笔不记(宁可没序数,也不要错序数)
575
+ title: hit ? hit.title : baseName(absPath),
576
+ mention: '@"' + String(absPath || '').replace(/\\/g, '/') + '"',
577
+ version: hit ? hit.version : 0
578
+ }
579
+ },
580
+ editor: function () { return document.querySelector('[data-ccv-md-body="1"]') },
581
+ range: function () { return savedRange }
582
+ }
583
+ } catch (_) {}
584
+ }
585
+
586
+ // ─── 划词栏(2026-10-15:把老画布面板那套搬过来)──────────────────
587
+ // 原来这里只有一个「问AI」按钮 ✗,而老面板有一套完整的 markdown 划词栏
588
+ // (图标库 CCV_ICONS / SELBAR_ITEMS / runCanvasCmd)—— 2026-10-11 随面板删了 ✗。
589
+ // 主人要求"把老画布的功能移过来" ✓ 分阶段搬;**这是阶段 1**:
590
+ // 问AI + 加粗 / 斜体 / 删除线 / 下划线 / 高亮 / 行内代码 / 清除格式 / 链接
591
+ // 后面阶段:块(代码块/引用/表格/任务列表/高亮块/缩进)→ 上传(图片/文件)→
592
+ // 右键菜单 + 三个下拉 → Ctrl+Z/Y 接管
593
+ //
594
+ // 从 git 里取的原件:git show 0a7cc65^:src/client/editor-panel.js
595
+ // ⚠️ 取的时候要用 execFileSync + 参数数组 —— node 的 execSync 走 cmd.exe,
596
+ // 而 **cmd 里 `^` 是转义符** ✗ 会把 "0a7cc65^:" 吃成 "0a7cc65:" ✗
597
+ var CCV_ICONS = {
598
+ ask: '<path d="M4 5h16v11H10l-6 4z"/>',
599
+ highlight: '<path d="M12 4.5a4.5 4.5 0 0 0-2.6 8.2c.6.4 1.1 1.2 1.1 2v.8h3v-.8c0-.8.5-1.6 1.1-2A4.5 4.5 0 0 0 12 4.5z"/><path d="M9.5 19h5M10.5 21.5h3"/><path d="M12 1.5v2M5.6 3.6L7 5M18.4 3.6L17 5M3 9h2M21.5 9h-2"/>',
600
+ clear: '<path d="M16 4l4 4-9 9H7l-3-3L16 4z"/><path d="M5 21h14"/><path d="M11 9l4 4"/>',
601
+ link: '<path d="M9.5 14.5a4 4 0 0 0 5.7 0l3.2-3.2a4 4 0 1 0-5.7-5.7l-1.6 1.6"/><path d="M14.5 9.5a4 4 0 0 0-5.7 0l-3.2 3.2a4 4 0 1 0 5.7 5.7l1.6-1.6"/>',
602
+ code: '<path d="M8.5 7.5L4 12l4.5 4.5M15.5 7.5L20 12l-4.5 4.5"/>',
603
+ codeBlock: '<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M9.5 10L7 12.5 9.5 15M14.5 10l2.5 2.5-2.5 2.5"/>',
604
+ quote: '<path d="M10 6H5.5v5H9c0 2.5-1.2 3.8-3.5 4M18.5 6H14v5h3.5c0 2.5-1.2 3.8-3.5 4"/>',
605
+ hr: '<path d="M4 12h16"/>',
606
+ table: '<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M3 9.5h18M9.5 9.5V20M15.5 9.5V20"/>',
607
+ task: '<rect x="4" y="4" width="16" height="16" rx="3"/><path d="M8.5 12.5l2.5 2.5 5-5.5"/>',
608
+ listUl: '<path d="M9 6h11M9 12h11M9 18h11"/><circle cx="4.5" cy="6" r="1.2"/><circle cx="4.5" cy="12" r="1.2"/><circle cx="4.5" cy="18" r="1.2"/>',
609
+ listOl: '<path d="M10 6h10M10 12h10M10 18h10"/><text x="2.5" y="8.5" font-size="7.5" stroke="none" fill="currentColor">1</text><text x="2.5" y="14.5" font-size="7.5" stroke="none" fill="currentColor">2</text><text x="2.5" y="20.5" font-size="7.5" stroke="none" fill="currentColor">3</text>',
610
+ uploadImage: '<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M12 16v-5M9.5 13.5L12 11l2.5 2.5"/>',
611
+ globe: '<circle cx="12" cy="12" r="8.5"/><path d="M3.5 12h17M12 3.5c3.2 2.7 3.2 14.3 0 17-3.2-2.7-3.2-14.3 0-17z"/>',
612
+ file: '<path d="M13.5 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8.5L13.5 3z"/><path d="M13.5 3v5.5H19"/>',
613
+ hlBlock: '<rect x="3" y="5" width="18" height="14" rx="3"/><path d="M7 12h10" stroke-width="2.4"/>',
614
+ indent: '<path d="M4 5h16M10 10h10M10 14h10M4 19h16M7 9.5L4.5 12 7 14.5"/>',
615
+ outdent: '<path d="M4 5h16M10 10h10M10 14h10M4 19h16M4.5 9.5L7 12l-2.5 2.5"/>'
616
+ }
617
+ function ccvIcon(name, size) {
618
+ var body = CCV_ICONS[name]
619
+ if (!body) return ''
620
+ var s = size || 15
621
+ return '<svg viewBox="0 0 24 24" width="' + s + '" height="' + s + '" fill="none" stroke="currentColor"'
622
+ + ' stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" style="display:block">' + body + '</svg>'
623
+ }
624
+ function escHtml(s) {
625
+ return String(s == null ? '' : s)
626
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
627
+ }
628
+ // 从**当前选区**取文字(跑命令前会先 restoreSelection ✓)
629
+ function selectionText() {
630
+ var sel = document.getSelection()
631
+ return sel ? sel.toString() : ''
632
+ }
633
+ function currentRange() {
634
+ var s = document.getSelection()
635
+ return (s && s.rangeCount) ? s.getRangeAt(0).cloneRange() : null
636
+ }
637
+ // 弹层该出现在哪(有选区就贴选区 ✓ 没有就用屏幕中间偏上 ✓)
638
+ function tableAnchor() {
639
+ try {
640
+ var rng = currentRange()
641
+ if (rng) {
642
+ var r = rng.getBoundingClientRect()
643
+ if (r && (r.width || r.height)) return { left: r.left, top: r.top, bottom: r.bottom }
644
+ }
645
+ } catch (_) {}
646
+ return { left: Math.max(40, window.innerWidth / 2 - 100), top: window.innerHeight / 2 - 60, bottom: window.innerHeight / 2 - 40 }
647
+ }
648
+ // 上传相关(阶段 3 搬过来的):上传走 host 的 /api/canvas/upload ✓
649
+ function assetDisplayUrl(p) {
650
+ if (/^attachments\//.test(p)) return '/api/canvas/file?name=' + encodeURIComponent(p)
651
+ return p
652
+ }
653
+ function uploadCanvasFile(file, onOk, onFail) {
654
+ try {
655
+ fetch('/api/canvas/upload?name=' + encodeURIComponent(file.name || 'file.bin'), {
656
+ method: 'POST',
657
+ headers: { 'Content-Type': file.type || 'application/octet-stream' },
658
+ body: file
659
+ }).then(function (r) { return r.json() }).then(function (d) {
660
+ if (d && d.ok && d.path) onOk(d.path, file.name || '图片')
661
+ else onFail(file)
662
+ }).catch(function () { onFail(file) })
663
+ } catch (e) { onFail(file) }
664
+ }
665
+ // 上传失败兜底:图片转 dataURL 内嵌(md 会变大,仅作 fallback ✓)
666
+ function fileToDataUrl(file, cb) {
667
+ try {
668
+ var fr = new FileReader()
669
+ fr.onload = function () { cb(String(fr.result)) }
670
+ fr.onerror = function () { cb('') }
671
+ fr.readAsDataURL(file)
672
+ } catch (_) { cb('') }
673
+ }
674
+ // 打开系统文件选择框(菜单点击是用户手势 → input.click() 能弹出 ✓)
675
+ function pickFile(accept, cb) {
676
+ var inp = document.createElement('input')
677
+ inp.type = 'file'
678
+ if (accept) inp.accept = accept
679
+ inp.style.display = 'none'
680
+ document.body.appendChild(inp)
681
+ inp.addEventListener('change', function () {
682
+ var f = inp.files && inp.files[0]
683
+ if (f) cb(f)
684
+ inp.remove()
685
+ })
686
+ inp.click()
687
+ }
688
+ // 异步上传完成后在原光标处插 HTML:先聚焦编辑器、找回范围再 insertHTML ✓
689
+ function insertHtmlAtRange(html, rng) {
690
+ var ed = editorEl()
691
+ if (!ed) return
692
+ try { ed.focus() } catch (_) {}
693
+ if (rng) {
694
+ try { var s = document.getSelection(); s.removeAllRanges(); s.addRange(rng) } catch (_) {}
695
+ }
696
+ document.execCommand('insertHTML', false, html)
697
+ }
698
+ function insertTableHtml(rows, cols) {
699
+ // ⚠️ 2026-10-29 主人(截图):插入的表格**行高不一致** ✗
700
+ // 原因:表头是 <th>表头</th>(有文字 → 占一行高)✓
701
+ // 而正文是 <td></td>(完全空 → 在 contentEditable 里高度塌成 0 ✗)
702
+ // → 表头一行很高、正文行挤成一条线 ✓ 光标所在的空单元格又会被撑高 ✓
703
+ // 修法:空的正文单元格里放一个 <br> ✓ 这样每行都是"一行文字"的高度 ✓
704
+ // (Word / Google Docs 插表格时也是这么做的 ✓)
705
+ var html = '<table>'
706
+ for (var r = 0; r < rows; r++) {
707
+ html += '<tr>'
708
+ for (var c = 0; c < cols; c++) html += (r === 0 ? '<th>表头</th>' : '<td><br></td>')
709
+ html += '</tr>'
710
+ }
711
+ html += '</table><div><br></div>'
712
+ document.execCommand('insertHTML', false, html)
713
+ }
714
+
715
+ // ─── 插入表格:网格选择器(成熟产品都这么做 ✓ 2026-10-15)──────────
716
+ // 老面板用的是 window.prompt("3×4") ✗ —— 看不见样子、也只能填行列 ✗
717
+ // Word / Google Docs / 飞书 / Notion 全都是:**悬停格子 → 实时显示"3×4" → 点一下插入** ✓
718
+ // 保留「自定义…」兜底(行列数很大时用 ✓)
719
+ function showTablePicker(anchorRect) {
720
+ var MAXR = 10, MAXC = 8
721
+ log('插入表格:打开网格选择器')
722
+ closeDropdowns()
723
+ saveSelection()
724
+ var dd = document.createElement('div')
725
+ dd.setAttribute('data-ccv-tablepick', '1')
726
+ dd.style.cssText = 'position:fixed;z-index:2147483002;background:var(--dsw-alias-bg-layer-1,#fff);'
727
+ + 'border:1px solid var(--dsw-alias-border-l1,rgba(128,128,128,.35));border-radius:8px;'
728
+ + 'box-shadow:0 6px 20px rgba(0,0,0,.18);padding:10px;font-size:13px;'
729
+ + 'color:var(--dsw-alias-label-primary,#1a1a1a);user-select:none;-webkit-user-select:none'
730
+ var title = document.createElement('div')
731
+ title.textContent = '插入表格'
732
+ title.style.cssText = 'font-size:11px;color:rgba(128,128,128,.85);margin-bottom:7px'
733
+ dd.appendChild(title)
734
+ var label = document.createElement('div')
735
+ label.textContent = '拖到想要的格数'
736
+ label.style.cssText = 'font-size:11px;color:rgba(128,128,128,.85);margin-bottom:6px'
737
+ var grid = document.createElement('div')
738
+ grid.style.cssText = 'display:grid;grid-template-columns:repeat(' + MAXC + ',18px);gap:2px'
739
+ var cells = []
740
+ function paint(r, c) {
741
+ for (var i = 0; i < cells.length; i++) {
742
+ var on = cells[i].r <= r && cells[i].c <= c
743
+ cells[i].el.style.background = on ? 'var(--dsw-alias-brand-primary,#4c8dff)' : 'rgba(128,128,128,.14)'
744
+ cells[i].el.style.opacity = on ? '0.85' : '1'
745
+ }
746
+ label.textContent = (r && c) ? (r + ' 行 × ' + c + ' 列') : '拖到想要的格数'
747
+ }
748
+ for (var r = 1; r <= MAXR; r++) {
749
+ for (var c = 1; c <= MAXC; c++) {
750
+ var el = document.createElement('div')
751
+ el.style.cssText = 'width:18px;height:18px;border-radius:3px;cursor:pointer;background:rgba(128,128,128,.14)'
752
+ ;(function (rr, cc, node) {
753
+ node.addEventListener('mouseenter', function () { paint(rr, cc) })
754
+ node.addEventListener('click', function (e) {
755
+ e.stopPropagation()
756
+ closeDropdowns()
757
+ restoreSelection()
758
+ insertTableHtml(rr, cc)
759
+ var ed = editorEl()
760
+ if (ed) { try { ed.focus() } catch (_) {} }
761
+ log('插入表格:' + rr + ' 行 × ' + cc + ' 列')
762
+ })
763
+ })(r, c, el)
764
+ cells.push({ r: r, c: c, el: el })
765
+ grid.appendChild(el)
766
+ }
767
+ }
768
+ dd.appendChild(grid)
769
+ var custom = document.createElement('div')
770
+ custom.textContent = '自定义行列数…'
771
+ custom.style.cssText = 'margin-top:8px;padding:5px 6px;border-radius:5px;cursor:pointer;font-size:12px;color:rgba(128,128,128,.95)'
772
+ custom.addEventListener('mouseenter', function () { custom.style.background = 'rgba(128,128,128,.12)' })
773
+ custom.addEventListener('mouseleave', function () { custom.style.background = 'transparent' })
774
+ custom.addEventListener('click', function (e) {
775
+ e.stopPropagation()
776
+ closeDropdowns()
777
+ var rc = window.prompt('表格行数×列数(如 3×4):', '3×3')
778
+ if (!rc) return
779
+ var mm = rc.match(/(\d+)\s*[×xX*]\s*(\d+)/)
780
+ if (!mm) { window.alert('格式不对,示例:3×4'); return }
781
+ restoreSelection()
782
+ insertTableHtml(Math.max(1, Math.min(50, +mm[1])), Math.max(1, Math.min(20, +mm[2])))
783
+ var ed2 = editorEl()
784
+ if (ed2) { try { ed2.focus() } catch (_) {} }
785
+ })
786
+ dd.appendChild(custom)
787
+ // 按下时别让浏览器把焦点/选区弄塌(否则 execCommand 什么都不做 ✗)—— 与划词栏同款 ✓
788
+ dd.addEventListener('mousedown', function (e) { e.preventDefault(); e.stopPropagation() })
789
+ document.body.appendChild(dd)
790
+ openDropdown = dd
791
+ // 定位:优先贴选区下方 ✓ 放不下就往上翻 ✓
792
+ var rect = anchorRect || { left: 200, bottom: 200, top: 180 }
793
+ var w = dd.offsetWidth, h = dd.offsetHeight
794
+ var x = Math.max(8, Math.min(rect.left, window.innerWidth - w - 8))
795
+ var y = rect.bottom + 8
796
+ if (y + h > window.innerHeight - 8) y = Math.max(8, (rect.top || 180) - h - 8)
797
+ dd.style.left = x + 'px'
798
+ dd.style.top = y + 'px'
799
+ setTimeout(function () {
800
+ document.addEventListener('mousedown', function close(ev) {
801
+ if (!dd.contains(ev.target)) { closeDropdowns(); savedRange = null; document.removeEventListener('mousedown', close) }
802
+ })
803
+ }, 0)
804
+ }
805
+ // ★ 选区保存/恢复 —— 老面板的规矩:**点按钮前必须先恢复选区** ✓
806
+ // 点划词栏/下拉菜单时编辑器已失焦、选区已塌 ✗ 不恢复的话"加粗"根本加不上 ✗
807
+ function saveSelection() {
808
+ try { savedRange = currentRange() } catch (_) {}
809
+ }
810
+ function restoreSelection() {
811
+ if (!savedRange) return
812
+ var sel = document.getSelection()
813
+ if (sel) { try { sel.removeAllRanges(); sel.addRange(savedRange) } catch (_) {} }
814
+ }
815
+ // 老面板的模块级格式状态(下拉菜单要读它 ✓ 比如"当前是几级标题")
816
+ var selectionFormats = { bold: false, italic: false, underline: false, heading: null }
817
+ function updateSelFormats(sel) {
818
+ try {
819
+ selectionFormats.bold = document.queryCommandState('bold')
820
+ selectionFormats.italic = document.queryCommandState('italic')
821
+ selectionFormats.underline = document.queryCommandState('underline')
822
+ selectionFormats.strikeThrough = document.queryCommandState('strikeThrough')
823
+ } catch (_) {}
824
+ selectionFormats.heading = null
825
+ selectionFormats.mark = false
826
+ try {
827
+ var node = sel.getRangeAt(0).startContainer
828
+ if (node.nodeType === 3) node = node.parentNode
829
+ while (node && node.nodeType === 1) {
830
+ var tag = node.tagName ? node.tagName.toLowerCase() : ''
831
+ if (['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].indexOf(tag) >= 0) { selectionFormats.heading = tag; break }
832
+ if (tag === 'mark') { selectionFormats.mark = true }
833
+ if (node.getAttribute && node.getAttribute('data-ccv-md-body')) break
834
+ node = node.parentNode
835
+ }
836
+ } catch (_) {}
837
+ return selectionFormats
838
+ }
839
+ // 划词栏的内容(老面板 SELBAR_ITEMS 原样搬 ✓ 阶段 1~4 全在)
840
+ var SELBAR_ITEMS = [
841
+ { label: ccvIcon('ask') + '<span style="margin-left:5px">问AI</span>', title: '将选中文本发送给 AI 问答', isAskAI: true },
842
+ { type: 'sep' },
843
+ { label: 'H', cmd: 'formatBlock', val: '<h2>', title: '标题', isHeading: true, hasDropdown: true },
844
+ { type: 'sep' },
845
+ { label: '≡', title: '对齐', hasDropdown: true, showAlign: true },
846
+ { type: 'sep' },
847
+ { label: '<b>B</b>', cmd: 'bold', title: '粗体(Ctrl+B)' },
848
+ { label: '<i>I</i>', cmd: 'italic', title: '斜体(Ctrl+I)' },
849
+ { label: '<s>S</s>', cmd: 'strikeThrough', title: '删除线' },
850
+ { label: '<span style="text-decoration:underline">U</span>', cmd: 'underline', title: '下划线' },
851
+ { label: ccvIcon('highlight'), cmd: 'highlight', title: '高亮' },
852
+ { label: ccvIcon('clear'), cmd: 'clearFormat', title: '清除格式' },
853
+ { type: 'sep' },
854
+ { label: '⋮', title: '更多功能', hasDropdown: true, showFullMenu: true }
855
+ ]
856
+ // 老面板的自定义命令(阶段 1~3 全搬 ✓ 其余的 default 交给 execCommand ✓)
857
+ function runCanvasCmd(cmd) {
858
+ // ★★ 先聚焦编辑区,再恢复选区(顺序不能反 ✗)★★
859
+ // execCommand('insertHTML'/'bold'…) **要求编辑区处于聚焦状态** ✗
860
+ // 点浮层(下拉菜单 / 网格选择器都是普通 div ✓)会把焦点弄到 body ✓
861
+ // → 命令**静默失败**(不报错、什么都不发生 ✗✗)
862
+ // focus() 会先把选区弄塌 ✗ 所以必须在 focus **之后**再 restoreSelection ✓
863
+ // (2026-10-15 主人报"表格插入失败",根因就是这条 ✗)
864
+ var ed0 = editorEl()
865
+ if (ed0) { try { ed0.focus() } catch (_) {} }
866
+ restoreSelection()
867
+ var text
868
+ switch (cmd) {
869
+ case 'highlight': {
870
+ text = selectionText()
871
+ if (!text) return true
872
+ document.execCommand('insertHTML', false, '<mark>' + escHtml(text) + '</mark>')
873
+ break
874
+ }
875
+ case 'inlineCode': {
876
+ text = selectionText() || 'code'
877
+ document.execCommand('insertHTML', false, '<code>' + escHtml(text) + '</code>')
878
+ break
879
+ }
880
+ case 'clearFormat': {
881
+ document.execCommand('removeFormat', false, null)
882
+ // removeFormat 管不到自定义的 <mark> 和 <a>:选区内的手动拆掉。
883
+ // 脚注引用 [1](href="#fn-…")是内容标记不是格式,保留 ✓
884
+ try {
885
+ var ed3 = editorEl()
886
+ var s3 = document.getSelection()
887
+ if (ed3 && s3 && s3.rangeCount) {
888
+ var rg = s3.getRangeAt(0)
889
+ var marks = ed3.querySelectorAll('mark')
890
+ for (var mi = marks.length - 1; mi >= 0; mi--) {
891
+ if (rg.intersectsNode(marks[mi])) {
892
+ var pMark = marks[mi].parentNode
893
+ while (marks[mi].firstChild) pMark.insertBefore(marks[mi].firstChild, marks[mi])
894
+ pMark.removeChild(marks[mi])
895
+ }
896
+ }
897
+ var links = ed3.querySelectorAll('a')
898
+ for (var ai = links.length - 1; ai >= 0; ai--) {
899
+ var lh = links[ai].getAttribute('href') || ''
900
+ if (lh.indexOf('#fn') === 0) continue
901
+ if (rg.intersectsNode(links[ai])) {
902
+ var pA = links[ai].parentNode
903
+ while (links[ai].firstChild) pA.insertBefore(links[ai].firstChild, links[ai])
904
+ pA.removeChild(links[ai])
905
+ }
906
+ }
907
+ }
908
+ } catch (_) {}
909
+ break
910
+ }
911
+ case 'codeBlock': {
912
+ text = selectionText() || 'code'
913
+ document.execCommand('insertHTML', false, '<pre><code>' + escHtml(text) + '</code></pre><div><br></div>')
914
+ break
915
+ }
916
+ case 'blockquote': {
917
+ text = selectionText() || '引用内容'
918
+ document.execCommand('insertHTML', false, '<blockquote><p>' + escHtml(text) + '</p></blockquote><div><br></div>')
919
+ break
920
+ }
921
+ case 'link': {
922
+ var url = window.prompt('请输入链接地址:', 'https://')
923
+ if (url) {
924
+ text = selectionText().trim() || '链接文字'
925
+ document.execCommand('insertHTML', false, '<a href="' + escHtml(url) + '">' + escHtml(text) + '</a>')
926
+ }
927
+ break
928
+ }
929
+ case 'footnote': {
930
+ // 脚注:自动生成编号,插入引用标记,并在文末添加定义 ✓(老面板原样)
931
+ var label = window.prompt('脚注标签(如 1、2、a):', '')
932
+ if (label) {
933
+ var fnHtml = '<a href="#fn-' + escHtml(label) + '" id="fnref-' + escHtml(label) + '" contenteditable="false" style="vertical-align:super;font-size:.75em">[' + escHtml(label) + ']</a>'
934
+ document.execCommand('insertHTML', false, fnHtml)
935
+ var ed = editorEl()
936
+ if (ed) {
937
+ var existing = ed.querySelector('#fn-' + label)
938
+ if (!existing) {
939
+ var fnDiv = ed.querySelector('.footnotes')
940
+ if (!fnDiv) {
941
+ fnDiv = document.createElement('div')
942
+ fnDiv.className = 'footnotes'
943
+ fnDiv.innerHTML = '<hr/>'
944
+ fnDiv.appendChild(document.createElement('ol'))
945
+ ed.appendChild(fnDiv)
946
+ }
947
+ var ol2 = fnDiv.querySelector('ol')
948
+ var li = document.createElement('li')
949
+ li.id = 'fn-' + label
950
+ li.innerHTML = '脚注 ' + escHtml(label) + ' 的内容 <a href="#fnref-' + escHtml(label) + '" contenteditable="false">↩</a>'
951
+ ol2.appendChild(li)
952
+ try { li.scrollIntoView({ behavior: 'smooth', block: 'center' }) } catch (_) {}
953
+ }
954
+ }
955
+ }
956
+ break
957
+ }
958
+ case 'uploadImage': {
959
+ var rngImg = currentRange()
960
+ pickFile('image/*', function (f) {
961
+ uploadCanvasFile(f, function (p, name) {
962
+ insertHtmlAtRange('<img src="' + escHtml(assetDisplayUrl(p)) + '" alt="' + escHtml(name) + '" style="max-width:100%;height:auto;vertical-align:middle"/>', rngImg)
963
+ }, function (f2) {
964
+ fileToDataUrl(f2, function (u) {
965
+ if (u) insertHtmlAtRange('<img src="' + u + '" alt="" style="max-width:100%;height:auto;vertical-align:middle"/>', rngImg)
966
+ else window.alert('图片上传失败')
967
+ })
968
+ })
969
+ })
970
+ break
971
+ }
972
+ case 'imageUrl': {
973
+ var iurl = window.prompt('请输入图片地址:', 'https://')
974
+ if (iurl) document.execCommand('insertHTML', false, '<img src="' + escHtml(iurl) + '" alt="" style="max-width:100%;height:auto;vertical-align:middle"/>')
975
+ break
976
+ }
977
+ case 'uploadFile': {
978
+ var rngFile = currentRange()
979
+ pickFile('', function (f) {
980
+ uploadCanvasFile(f, function (p, name) {
981
+ insertHtmlAtRange('<a href="' + escHtml(assetDisplayUrl(p)) + '">' + escHtml(name) + '</a>', rngFile)
982
+ }, function () { window.alert('文件上传失败') })
983
+ })
984
+ break
985
+ }
986
+ case 'insertTable': {
987
+ // 成熟产品的做法:网格选择器 ✓(原来那个 prompt 是权宜 ✗ 已挪到选择器的"自定义…"里)
988
+ showTablePicker(tableAnchor())
989
+ break
990
+ }
991
+ case 'insertTaskList': {
992
+ document.execCommand('insertHTML', false, '<ul><li><input type="checkbox"> 任务事项</li></ul><div><br></div>')
993
+ break
994
+ }
995
+ case 'highlightBlock': {
996
+ text = selectionText()
997
+ var inner = text ? escHtml(text) : '高亮内容'
998
+ document.execCommand('insertHTML', false, '<div class="ccv-hl"><p>' + inner + '</p></div><div><br></div>')
999
+ break
1000
+ }
1001
+ case 'indent': document.execCommand('indent', false, null); break
1002
+ case 'outdent': document.execCommand('outdent', false, null); break
1003
+ default: return false
1004
+ }
1005
+ var ed4 = editorEl()
1006
+ if (ed4) { try { ed4.focus() } catch (_) {} }
1007
+ return true
1008
+ }
1009
+ // ─── 下拉菜单(阶段 4 搬过来的:openDropdownAt 原样 ✓)──────────────
1010
+ var openDropdown = null
1011
+ function closeDropdowns() {
1012
+ if (openDropdown && openDropdown.parentNode) openDropdown.parentNode.removeChild(openDropdown)
1013
+ openDropdown = null
1014
+ }
1015
+ // 老面板的 showDropdown(anchor, items, cb) —— 贴按钮下方弹出 ✓
1016
+ function showDropdown(anchor, items, onSelect) {
1017
+ var r = anchor && anchor.getBoundingClientRect ? anchor.getBoundingClientRect() : { left: 40, bottom: 40 }
1018
+ openDropdownAt(r.left, r.bottom + 6, items, onSelect)
1019
+ }
1020
+ function openDropdownAt(left, top, items, onSelect) {
1021
+ closeDropdowns()
1022
+ saveSelection()
1023
+ var dd = document.createElement('div')
1024
+ dd.setAttribute('data-ccv-dropdown', '1')
1025
+ // 不透明底 ✓(老面板这里用的就是 bg-overlay,但主人要求划词栏不透明 —— 菜单同规矩 ✓)
1026
+ dd.style.cssText = 'position:fixed;z-index:2147483001;background:var(--dsw-alias-bg-layer-1,#fff);'
1027
+ + 'border:1px solid var(--dsw-alias-border-l1,rgba(128,128,128,.35));border-radius:8px;'
1028
+ + 'box-shadow:0 6px 20px rgba(0,0,0,.18);padding:4px 0;min-width:160px;max-width:320px;'
1029
+ + 'font-size:13px;color:var(--dsw-alias-label-primary,#1a1a1a);max-height:72vh;overflow-y:auto'
1030
+ function iconSpan(icon) {
1031
+ var ic = document.createElement('span')
1032
+ if (icon.indexOf('<svg') === 0) ic.innerHTML = icon
1033
+ else ic.textContent = icon
1034
+ ic.style.cssText = 'width:18px;display:flex;justify-content:center;flex:0 0 auto'
1035
+ return ic
1036
+ }
1037
+ function addRow(item) {
1038
+ var row = document.createElement('div')
1039
+ row.style.cssText = 'padding:7px 14px;cursor:pointer;display:flex;align-items:center;gap:9px;transition:background .1s;white-space:nowrap'
1040
+ if (item.icon) row.appendChild(iconSpan(item.icon))
1041
+ var lb = document.createElement('span')
1042
+ lb.textContent = item.label
1043
+ row.appendChild(lb)
1044
+ if (item.style) row.style.cssText += ';' + item.style
1045
+ if (item.active) { row.style.boxShadow = 'inset 0 0 0 1px currentColor'; row.style.fontWeight = '600' }
1046
+ row.addEventListener('mouseenter', function () { row.style.background = 'rgba(128,128,128,.12)' })
1047
+ row.addEventListener('mouseleave', function () { row.style.background = 'transparent' })
1048
+ row.addEventListener('click', function (e) { e.stopPropagation(); closeDropdowns(); onSelect(item) })
1049
+ dd.appendChild(row)
1050
+ }
1051
+ items.forEach(function (item) {
1052
+ if (item.type === 'sep') {
1053
+ var sep = document.createElement('div')
1054
+ sep.style.cssText = 'height:1px;background:rgba(128,128,128,.15);margin:4px 0'
1055
+ dd.appendChild(sep)
1056
+ return
1057
+ }
1058
+ if (item.type === 'header') {
1059
+ var hd = document.createElement('div')
1060
+ hd.style.cssText = 'padding:6px 14px 2px;font-size:11px;color:rgba(128,128,128,.75);letter-spacing:.5px;user-select:none;white-space:nowrap'
1061
+ hd.textContent = item.label
1062
+ dd.appendChild(hd)
1063
+ return
1064
+ }
1065
+ if (item.type === 'row') {
1066
+ var rr = document.createElement('div')
1067
+ rr.style.cssText = 'display:flex;align-items:center;gap:2px;padding:2px 8px'
1068
+ item.items.forEach(function (sub) {
1069
+ var b = document.createElement('div')
1070
+ b.style.cssText = 'flex:1 1 0;display:flex;align-items:center;justify-content:center;gap:5px;padding:7px 8px;cursor:pointer;border-radius:6px;white-space:nowrap;min-width:0'
1071
+ if (sub.icon) b.appendChild(iconSpan(sub.icon))
1072
+ var sl = document.createElement('span')
1073
+ sl.textContent = sub.label
1074
+ b.appendChild(sl)
1075
+ b.addEventListener('mouseenter', function () { b.style.background = 'rgba(128,128,128,.12)' })
1076
+ b.addEventListener('mouseleave', function () { b.style.background = 'transparent' })
1077
+ b.addEventListener('click', function (e) { e.stopPropagation(); closeDropdowns(); onSelect(sub) })
1078
+ rr.appendChild(b)
1079
+ })
1080
+ dd.appendChild(rr)
1081
+ return
1082
+ }
1083
+ addRow(item)
1084
+ })
1085
+ document.body.appendChild(dd)
1086
+ openDropdown = dd
1087
+ // 按下时别让浏览器把焦点/选区弄塌 ✓(2026-10-15:下拉菜单原来漏了这道守卫 ✗
1088
+ // → 从菜单点「代码块 / 引用 / 表格…」时焦点已经跑到 body ✓
1089
+ // → runCanvasCmd 里的 execCommand 静默失败 ✗)
1090
+ dd.addEventListener('mousedown', function (e) { e.preventDefault(); e.stopPropagation() })
1091
+ // 先挂载量尺寸,再夹到视口内;下方放不下就翻到坐标上方 ✓
1092
+ var ddWidth = dd.offsetWidth
1093
+ var ddHeight = dd.offsetHeight
1094
+ var x = Math.max(8, Math.min(left, window.innerWidth - ddWidth - 8))
1095
+ var y = top
1096
+ if (y + ddHeight > window.innerHeight - 8) y = Math.max(8, top - ddHeight - 4)
1097
+ dd.style.left = x + 'px'
1098
+ dd.style.top = y + 'px'
1099
+ // 点击外部关闭;没点菜单就关掉的话,保存的选区一并作废(防止之后误插到旧位置 ✓)
1100
+ setTimeout(function () {
1101
+ document.addEventListener('mousedown', function close(ev) {
1102
+ if (!dd.contains(ev.target)) { closeDropdowns(); savedRange = null; document.removeEventListener('mousedown', close) }
1103
+ })
1104
+ }, 0)
1105
+ }
1106
+ function showHeadingDropdown(anchor) {
1107
+ var currentH = selectionFormats.heading
1108
+ var items = [
1109
+ { label: '正文', val: '<p>', active: !currentH },
1110
+ { type: 'sep' },
1111
+ { label: '一级标题', val: '<h1>', active: currentH === 'h1', style: 'font-size:18px;font-weight:700' },
1112
+ { label: '二级标题', val: '<h2>', active: currentH === 'h2', style: 'font-size:16px;font-weight:600' },
1113
+ { label: '三级标题', val: '<h3>', active: currentH === 'h3', style: 'font-size:14px;font-weight:600' },
1114
+ { label: '四级标题', val: '<h4>', active: currentH === 'h4', style: 'font-size:13px;font-weight:600' },
1115
+ { label: '五级标题', val: '<h5>', active: currentH === 'h5', style: 'font-size:12px;font-weight:500' },
1116
+ { label: '六级标题', val: '<h6>', active: currentH === 'h6', style: 'font-size:11px;font-weight:500' }
1117
+ ]
1118
+ showDropdown(anchor, items, function (item) {
1119
+ restoreSelection()
1120
+ document.execCommand('formatBlock', false, item.val)
1121
+ var ed = editorEl()
1122
+ if (ed) { try { ed.focus() } catch (_) {} }
1123
+ })
1124
+ }
1125
+ function showAlignDropdown(anchor) {
1126
+ var items = [
1127
+ { label: '左对齐', cmd: 'justifyLeft' },
1128
+ { label: '居中对齐', cmd: 'justifyCenter' },
1129
+ { label: '右对齐', cmd: 'justifyRight' }
1130
+ ]
1131
+ showDropdown(anchor, items, function (item) {
1132
+ restoreSelection()
1133
+ document.execCommand(item.cmd, false, null)
1134
+ var ed = editorEl()
1135
+ if (ed) { try { ed.focus() } catch (_) {} }
1136
+ })
1137
+ }
1138
+ function showFullMenu(anchor) {
1139
+ var items = [
1140
+ { type: 'header', label: '格式' },
1141
+ { label: '行内代码', cmd: 'inlineCode', icon: ccvIcon('code') },
1142
+ { label: '高亮', cmd: 'highlight', icon: ccvIcon('highlight') },
1143
+ { label: '清除格式', cmd: 'clearFormat', icon: ccvIcon('clear') },
1144
+ { label: '脚注', cmd: 'footnote', icon: '¹' },
1145
+ { type: 'header', label: '插入' },
1146
+ { label: '上传图片…', cmd: 'uploadImage', icon: ccvIcon('uploadImage') },
1147
+ { label: '网络图片…', cmd: 'imageUrl', icon: ccvIcon('globe') },
1148
+ { label: '上传文件…', cmd: 'uploadFile', icon: ccvIcon('file') },
1149
+ { label: '插入表格…', cmd: 'insertTable', icon: ccvIcon('table') },
1150
+ { label: '任务列表', cmd: 'insertTaskList', icon: ccvIcon('task') },
1151
+ { label: '无序列表', cmd: 'insertUnorderedList', icon: ccvIcon('listUl') },
1152
+ { label: '有序列表', cmd: 'insertOrderedList', icon: ccvIcon('listOl') },
1153
+ { label: '插入链接…', cmd: 'link', icon: ccvIcon('link') },
1154
+ { type: 'header', label: '块' },
1155
+ { type: 'row', items: [
1156
+ { label: '代码块', cmd: 'codeBlock', icon: ccvIcon('codeBlock') },
1157
+ { label: '引用', cmd: 'blockquote', icon: ccvIcon('quote') },
1158
+ { label: '分割线', cmd: 'insertHorizontalRule', icon: ccvIcon('hr') },
1159
+ { label: '高亮块', cmd: 'highlightBlock', icon: ccvIcon('hlBlock') }
1160
+ ] },
1161
+ { type: 'header', label: '缩进' },
1162
+ { label: '增加缩进', cmd: 'indent', icon: ccvIcon('indent') },
1163
+ { label: '减少缩进', cmd: 'outdent', icon: ccvIcon('outdent') }
1164
+ ]
1165
+ showDropdown(anchor, items, function (item) {
1166
+ restoreSelection()
1167
+ if (!runCanvasCmd(item.cmd)) {
1168
+ try { document.execCommand(item.cmd, false, item.val) } catch (_) {}
1169
+ var ed = editorEl()
1170
+ if (ed) { try { ed.focus() } catch (_) {} }
1171
+ }
1172
+ })
1173
+ }
1174
+ // ─── 右键菜单(阶段 4 搬过来的 ✓)────────────────────────────────
1175
+ function showContextMenu(x, y) {
1176
+ var hasSel = selectionText().trim().length > 0
1177
+ var items = [
1178
+ { label: '问AI', cmd: 'askAI', icon: ccvIcon('ask') },
1179
+ { type: 'sep' },
1180
+ { type: 'header', label: '常用' },
1181
+ { label: '上传图片…', cmd: 'uploadImage', icon: ccvIcon('uploadImage') },
1182
+ { label: '网络图片…', cmd: 'imageUrl', icon: ccvIcon('globe') },
1183
+ { label: '上传文件…', cmd: 'uploadFile', icon: ccvIcon('file') },
1184
+ { label: '插入表格…', cmd: 'insertTable', icon: ccvIcon('table') },
1185
+ { label: '插入链接…', cmd: 'link', icon: ccvIcon('link') },
1186
+ { type: 'row', items: [
1187
+ { label: '无序列表', cmd: 'insertUnorderedList', icon: ccvIcon('listUl') },
1188
+ { label: '有序列表', cmd: 'insertOrderedList', icon: ccvIcon('listOl') },
1189
+ { label: '任务列表', cmd: 'insertTaskList', icon: ccvIcon('task') }
1190
+ ] },
1191
+ { type: 'sep' },
1192
+ { type: 'header', label: '块' },
1193
+ { type: 'row', items: [
1194
+ { label: '代码块', cmd: 'codeBlock', icon: ccvIcon('codeBlock') },
1195
+ { label: '引用', cmd: 'blockquote', icon: ccvIcon('quote') },
1196
+ { label: '分割线', cmd: 'insertHorizontalRule', icon: ccvIcon('hr') },
1197
+ { label: '高亮块', cmd: 'highlightBlock', icon: ccvIcon('hlBlock') }
1198
+ ] },
1199
+ { type: 'sep' },
1200
+ { label: '增加缩进', cmd: 'indent', icon: ccvIcon('indent') },
1201
+ { label: '减少缩进', cmd: 'outdent', icon: ccvIcon('outdent') }
1202
+ ]
1203
+ if (hasSel) {
1204
+ items.push(
1205
+ { type: 'sep' },
1206
+ { type: 'header', label: '转换为' },
1207
+ { label: '代码块', cmd: 'codeBlock', icon: ccvIcon('codeBlock') },
1208
+ { label: '引用', cmd: 'blockquote', icon: ccvIcon('quote') },
1209
+ { label: '高亮', cmd: 'highlight', icon: ccvIcon('highlight') },
1210
+ { label: '链接', cmd: 'link', icon: ccvIcon('link') }
1211
+ )
1212
+ }
1213
+ openDropdownAt(x, y, items, function (item) {
1214
+ if (item.cmd === 'askAI') { askAi(); return }
1215
+ if (!runCanvasCmd(item.cmd)) document.execCommand(item.cmd, false, item.val)
1216
+ })
1217
+ }
1218
+
1219
+ // ─── 表格单元格导航(Tab / Shift+Tab)──────────────────────────
1220
+ // 成熟产品都这么做 ✓ 写表格时手不用离开键盘 ✓
1221
+ function cellOf() {
1222
+ try {
1223
+ var sel = document.getSelection()
1224
+ if (!sel || !sel.rangeCount) return null
1225
+ var n = sel.getRangeAt(0).startContainer
1226
+ var el = (n && n.nodeType === 3) ? n.parentNode : n
1227
+ return (el && el.closest) ? el.closest('td,th') : null
1228
+ } catch (_) { return null }
1229
+ }
1230
+ function moveCell(back) {
1231
+ try {
1232
+ var cell = cellOf()
1233
+ if (!cell) return
1234
+ var table = cell.closest('table')
1235
+ if (!table) return
1236
+ var cells = table.querySelectorAll('th,td')
1237
+ var idx = Array.prototype.indexOf.call(cells, cell)
1238
+ var next = cells[idx + (back ? -1 : 1)]
1239
+ if (!next && !back) {
1240
+ // 到最后一个单元格再按 Tab → 自动加一行(列数照抄 ✓)—— Word / 飞书 的行为 ✓
1241
+ var trs = table.querySelectorAll('tr')
1242
+ var cols = trs.length ? trs[trs.length - 1].querySelectorAll('th,td').length : 0
1243
+ if (!cols) return
1244
+ var tr = document.createElement('tr')
1245
+ for (var i = 0; i < cols; i++) tr.appendChild(document.createElement('td'))
1246
+ table.appendChild(tr)
1247
+ next = tr.querySelector('td')
1248
+ log('表格:Tab 到末尾 → 自动加了一行(' + cols + ' 列)')
1249
+ }
1250
+ if (!next) return
1251
+ var rng = document.createRange()
1252
+ rng.selectNodeContents(next)
1253
+ rng.collapse(false) // 插入点落在单元格里 ✓
1254
+ var sel2 = document.getSelection()
1255
+ sel2.removeAllRanges()
1256
+ sel2.addRange(rng)
1257
+ } catch (e) { log('表格 Tab 出错: ' + (e && e.message)) }
1258
+ }
1259
+
1260
+ // ─── 表格列宽拖拽(2026-10-15 加)──────────────────────────────
1261
+ // 成熟产品(Word / Google Docs / 飞书)都能拖列边界 ✓ 我们原来完全不能 ✗
1262
+ // 存法:把列宽写成 <colgroup><col style="width:40%"> ✓
1263
+ // 由 htmlToMd 序列化成表格后面的一行注释 <!-- ccv-table: 40,60 --> ✓
1264
+ // 由 mdToHtml 读回来 ✓ —— 表格本身保持 markdown 的可读形式 ✓
1265
+ // 拖完**手动派发一次 input 事件** ✓ —— 否则 onInput 不会跑、脏标记与自动保存接不上 ✗
1266
+ // (execCommand 会自己触发 input ✓ 但直接改 DOM 不会 ✓)
1267
+ function fireInput() {
1268
+ try {
1269
+ var ed = editorEl()
1270
+ if (ed) ed.dispatchEvent(new Event('input', { bubbles: true }))
1271
+ } catch (_) {}
1272
+ }
1273
+ // 表格的列数(按第一行算)
1274
+ function colCount(table) {
1275
+ try {
1276
+ var tr = table.querySelector('tr')
1277
+ return tr ? tr.querySelectorAll('th,td').length : 0
1278
+ } catch (_) { return 0 }
1279
+ }
1280
+ // 确保有 colgroup(列数与表格一致 ✓)—— 没有就补一个全 auto 的 ✓
1281
+ function ensureColgroup(table) {
1282
+ var n = colCount(table)
1283
+ if (!n) return null
1284
+ var cg = table.querySelector('colgroup')
1285
+ if (!cg || cg.querySelectorAll('col').length !== n) {
1286
+ if (cg && cg.parentNode) cg.parentNode.removeChild(cg)
1287
+ cg = document.createElement('colgroup')
1288
+ for (var i = 0; i < n; i++) cg.appendChild(document.createElement('col'))
1289
+ table.insertBefore(cg, table.firstChild)
1290
+ }
1291
+ return cg
1292
+ }
1293
+ // ─── 表格拖拽(列宽 / 行高)2026-10-15 ────────────────────────
1294
+ // ⚠️ 主人报「拉高度时经常卡,向下拉 8,卡一下退回 6」—— 两个原因:
1295
+ // ① 拖动中途**变成了浏览器的文本选择** ✗
1296
+ // 鼠标稍微偏出 ±5px 热区时,mousedown 没被我们接管 ✓
1297
+ // → 变成"选文字"拖动 → 高度停住、看着像弹回 ✗
1298
+ // 修:拖动期间给 body 上 user-select:none ✓(并 preventDefault ✓)
1299
+ // ② 每个 mousemove 都在 **读布局**(getBoundingClientRect)+ **写布局**(style)
1300
+ // → 强制同步排版,读写抖动 = 卡 ✓
1301
+ // 修:用 requestAnimationFrame 节流(**一帧只写一次** ✓)
1302
+ // 并且拖动期间**跳过光标探测**(那里面全是读布局 ✗)
1303
+ var tableDragging = false
1304
+
1305
+ // 拖动期间禁掉文本选择 ✓(不然鼠标一偏就变成选文字 ✗)
1306
+ function lockSelect(on) {
1307
+ try {
1308
+ document.body.style.userSelect = on ? 'none' : ''
1309
+ document.body.style.webkitUserSelect = on ? 'none' : ''
1310
+ } catch (_) {}
1311
+ }
1312
+
1313
+ function startColDrag(e, cell) {
1314
+ try {
1315
+ var table = cell.closest('table')
1316
+ if (!table) return
1317
+ e.preventDefault()
1318
+ e.stopPropagation()
1319
+ var cg = ensureColgroup(table)
1320
+ if (!cg) return
1321
+ var cols = cg.querySelectorAll('col')
1322
+ var cells = Array.prototype.slice.call(table.querySelector('tr').querySelectorAll('th,td'))
1323
+ var idx = cells.indexOf(cell)
1324
+ if (idx < 0) return
1325
+ var tRect = table.getBoundingClientRect()
1326
+ var startX = e.clientX
1327
+ var startW = cells[idx].getBoundingClientRect().width
1328
+ tableDragging = true
1329
+ lockSelect(true)
1330
+ var raf = 0, lastX = startX
1331
+ try { table.style.tableLayout = 'fixed' } catch (_) {}
1332
+ function apply() {
1333
+ raf = 0
1334
+ var w = Math.max(30, startW + (lastX - startX))
1335
+ var pct = Math.max(4, Math.min(96, Math.round(w / tRect.width * 1000) / 10))
1336
+ cols[idx].setAttribute('style', 'width:' + pct + '%')
1337
+ }
1338
+ function onMove(ev) {
1339
+ lastX = ev.clientX
1340
+ if (raf) return // ★ 一帧只写一次 ✓
1341
+ raf = requestAnimationFrame(apply)
1342
+ }
1343
+ function onUp() {
1344
+ document.removeEventListener('mousemove', onMove, true)
1345
+ document.removeEventListener('mouseup', onUp, true)
1346
+ if (raf) { cancelAnimationFrame(raf); raf = 0 }
1347
+ apply() // 收尾时把最后一帧落定 ✓
1348
+ tableDragging = false
1349
+ lockSelect(false)
1350
+ log('表格列宽:第 ' + (idx + 1) + ' 列 → ' + (cols[idx].getAttribute('style') || ''))
1351
+ fireInput() // ★ 让脏标记 / 草稿 / 自动保存接上 ✓
1352
+ }
1353
+ document.addEventListener('mousemove', onMove, true)
1354
+ document.addEventListener('mouseup', onUp, true)
1355
+ log('开始拖列宽:第 ' + (idx + 1) + ' 列(表格宽 ' + Math.round(tRect.width) + 'px)')
1356
+ } catch (err) { tableDragging = false; lockSelect(false); log('拖列宽出错: ' + (err && err.message)) }
1357
+ }
1358
+ // 双击列边界 → 恢复自动宽度(成熟产品:按内容自适应 ✓)
1359
+ function resetColWidth(cell) {
1360
+ try {
1361
+ var table = cell.closest('table')
1362
+ var cg = table && table.querySelector('colgroup')
1363
+ if (!cg) return
1364
+ var cells = Array.prototype.slice.call(table.querySelector('tr').querySelectorAll('th,td'))
1365
+ var idx = cells.indexOf(cell)
1366
+ var cols = cg.querySelectorAll('col')
1367
+ if (idx >= 0 && cols[idx]) cols[idx].removeAttribute('style')
1368
+ table.style.tableLayout = ''
1369
+ log('表格列宽:第 ' + (idx + 1) + ' 列恢复自动 ✓')
1370
+ fireInput()
1371
+ } catch (_) {}
1372
+ }
1373
+ // ─── 表格行高拖拽(2026-10-15 加)──────────────────────────────
1374
+ // 成熟产品:行高默认由**内容撑开** ✓(Word / Google Docs / 飞书 都是)
1375
+ // 但 Word / 飞书 允许拖行边界固定高度 ✓ 这里照做 ✓
1376
+ // 存法:<tr style="height:32px"> ✓ 由 htmlToMd 写成 <!-- ccv-table-h: 32,auto --> ✓
1377
+ function startRowDrag(e, tr) {
1378
+ try {
1379
+ var table = tr.closest('table')
1380
+ if (!table) return
1381
+ e.preventDefault()
1382
+ e.stopPropagation()
1383
+ var startY = e.clientY
1384
+ var startH = tr.getBoundingClientRect().height
1385
+ var rows = Array.prototype.slice.call(table.querySelectorAll('tr'))
1386
+ var idx = rows.indexOf(tr)
1387
+ if (idx < 0) return
1388
+ tableDragging = true
1389
+ lockSelect(true)
1390
+ var raf = 0, lastY = startY
1391
+ function apply() {
1392
+ raf = 0
1393
+ var h = Math.max(24, startH + (lastY - startY))
1394
+ tr.style.height = Math.round(h) + 'px'
1395
+ }
1396
+ function onMove(ev) {
1397
+ lastY = ev.clientY
1398
+ if (raf) return // ★ 一帧只写一次 ✓(原来每个 mousemove 都写 ✗ = 卡)
1399
+ raf = requestAnimationFrame(apply)
1400
+ }
1401
+ function onUp() {
1402
+ document.removeEventListener('mousemove', onMove, true)
1403
+ document.removeEventListener('mouseup', onUp, true)
1404
+ if (raf) { cancelAnimationFrame(raf); raf = 0 }
1405
+ apply() // 收尾把最后一帧落定 ✓
1406
+ tableDragging = false
1407
+ lockSelect(false)
1408
+ log('表格行高:第 ' + (idx + 1) + ' 行 → ' + (tr.getAttribute('style') || '(无)'))
1409
+ fireInput() // ★ 让脏标记 / 草稿 / 自动保存接上 ✓
1410
+ }
1411
+ document.addEventListener('mousemove', onMove, true)
1412
+ document.addEventListener('mouseup', onUp, true)
1413
+ log('开始拖行高:第 ' + (idx + 1) + ' 行(原高 ' + Math.round(startH) + 'px)')
1414
+ } catch (err) { tableDragging = false; lockSelect(false); log('拖行高出错: ' + (err && err.message)) }
1415
+ }
1416
+ // 双击行边界 → 恢复"由内容撑开"(成熟产品的默认 ✓)
1417
+ function resetRowHeight(tr) {
1418
+ try {
1419
+ tr.style.height = ''
1420
+ var rows = Array.prototype.slice.call(tr.closest('table').querySelectorAll('tr'))
1421
+ log('表格行高:第 ' + (rows.indexOf(tr) + 1) + ' 行恢复自动 ✓')
1422
+ fireInput()
1423
+ } catch (_) {}
1424
+ }
1425
+
1426
+ // 鼠标划过列边界 → 光标变 ↔(并记住当前压着哪一格 ✓)
1427
+ function bindTableResize(root) {
1428
+ if (!root || root.__ccvTableBound) return
1429
+ root.__ccvTableBound = true
1430
+ // 表格工具条也靠这里同步(光标进出表格 ✓ 键盘移动光标 ✓)
1431
+ root.addEventListener('keyup', function () { tableBarSyncSoon() })
1432
+ root.addEventListener('click', function () { tableBarSyncSoon() })
1433
+ var hot = null
1434
+ var hotRow = null
1435
+ function clearCursors() {
1436
+ if (hot) { hot.style.cursor = ''; hot = null }
1437
+ if (hotRow) { hotRow.style.cursor = ''; hotRow = null }
1438
+ }
1439
+ root.addEventListener('mousemove', function (e) {
1440
+ // ★ 正在拖列宽/行高时**别做光标探测** ✗ —— 那里面全是 getBoundingClientRect(读布局 ✓)
1441
+ // 和 style.cursor 写入(写布局 ✓),一读一写就是强制同步排版 = 卡 ✓
1442
+ if (tableDragging) return
1443
+ var cell = (e.target && e.target.closest) ? e.target.closest('th,td') : null
1444
+ if (!cell) { clearCursors(); return }
1445
+ var r = cell.getBoundingClientRect()
1446
+ // ① 列边界(单元格右边界 ±6px ✓ 优先,因为竖向拖动更常用 ✓)
1447
+ if (Math.abs(e.clientX - r.right) <= 6) {
1448
+ if (hotRow) { hotRow.style.cursor = ''; hotRow = null }
1449
+ if (hot !== cell) { if (hot) hot.style.cursor = ''; hot = cell; cell.style.cursor = 'col-resize' }
1450
+ return
1451
+ }
1452
+ // ② 行边界(行下边界 ±6px ✓)
1453
+ var tr = cell.parentNode
1454
+ if (tr && tr.tagName === 'TR') {
1455
+ var rr = tr.getBoundingClientRect()
1456
+ if (Math.abs(e.clientY - rr.bottom) <= 6) {
1457
+ if (hot) { hot.style.cursor = ''; hot = null }
1458
+ if (hotRow !== tr) { if (hotRow) hotRow.style.cursor = ''; hotRow = tr; tr.style.cursor = 'row-resize' }
1459
+ return
1460
+ }
1461
+ }
1462
+ clearCursors()
1463
+ })
1464
+ root.addEventListener('mousedown', function (e) {
1465
+ var cell = (e.target && e.target.closest) ? e.target.closest('th,td') : null
1466
+ if (!cell) return
1467
+ var r = cell.getBoundingClientRect()
1468
+ if (Math.abs(e.clientX - r.right) <= 6) { startColDrag(e, cell); return }
1469
+ var tr = cell.parentNode
1470
+ if (tr && tr.tagName === 'TR') {
1471
+ var rr = tr.getBoundingClientRect()
1472
+ if (Math.abs(e.clientY - rr.bottom) <= 6) startRowDrag(e, tr)
1473
+ }
1474
+ }, true)
1475
+ root.addEventListener('dblclick', function (e) {
1476
+ var cell = (e.target && e.target.closest) ? e.target.closest('th,td') : null
1477
+ if (!cell) return
1478
+ var r = cell.getBoundingClientRect()
1479
+ if (Math.abs(e.clientX - r.right) <= 6) { e.preventDefault(); resetColWidth(cell); return }
1480
+ var tr = cell.parentNode
1481
+ if (tr && tr.tagName === 'TR') {
1482
+ var rr = tr.getBoundingClientRect()
1483
+ if (Math.abs(e.clientY - rr.bottom) <= 6) { e.preventDefault(); resetRowHeight(tr) }
1484
+ }
1485
+ })
1486
+ }
1487
+
1488
+ // ─── 表格工具条(2026-10-15)──────────────────────────────────
1489
+ // 成熟产品(Google Docs 在表格上方 ✓ Notion 在左侧 ✓ 飞书在右上方 ✓)
1490
+ // 光标进表格就出现 ✓ 出表格就消失 ✓
1491
+ //
1492
+ // ⚠️ **不做合并/拆分单元格** ✗ —— 不是偷懒:
1493
+ // markdown 表格语法**表达不了 colspan/rowspan** ✗
1494
+ // 我们的 htmlToMd 会把表格压平成 | a | b | ✗ → 合并一次结构就丢了(内容错位 ✗✗)
1495
+ // 成熟产品能合并是因为它们存的是富文本模型 ✓ 我们存 markdown ✓
1496
+ var tblBar = null
1497
+ var tblBarFor = null
1498
+ var tblBarRaf = 0
1499
+
1500
+ function tblBtn(label, title, fn) {
1501
+ var b = document.createElement('button')
1502
+ b.innerHTML = label
1503
+ b.title = title
1504
+ b.style.cssText = 'padding:3px 7px;border:none;background:transparent;color:inherit;border-radius:5px;'
1505
+ + 'font-size:12px;cursor:pointer;font-family:inherit;display:flex;align-items:center;justify-content:center;'
1506
+ + 'min-width:26px;height:24px;white-space:nowrap;line-height:1'
1507
+ b.addEventListener('mouseenter', function () { b.style.background = 'rgba(128,128,128,.14)' })
1508
+ b.addEventListener('mouseleave', function () { b.style.background = 'transparent' })
1509
+ b.addEventListener('click', function (e) {
1510
+ e.stopPropagation()
1511
+ try { fn() } catch (err) { log('表格工具条出错: ' + (err && err.message)) }
1512
+ })
1513
+ return b
1514
+ }
1515
+ function ensureTableBar() {
1516
+ if (tblBar && tblBar.parentNode) return tblBar
1517
+ tblBar = document.createElement('div')
1518
+ tblBar.setAttribute('data-ccv-tblbar', '1')
1519
+ // 不透明(与划词栏/下拉同规矩 ✓)
1520
+ tblBar.style.cssText = 'position:fixed;z-index:2147483003;display:none;align-items:center;gap:1px;'
1521
+ + 'width:max-content;padding:2px 4px;border-radius:8px;'
1522
+ + 'background:var(--dsw-alias-bg-layer-1,#fff);'
1523
+ + 'color:var(--dsw-alias-label-primary,#1a1a1a);'
1524
+ + 'border:1px solid var(--dsw-alias-border-l1,rgba(128,128,128,.3));'
1525
+ + 'box-shadow:0 4px 14px rgba(0,0,0,.16);user-select:none;-webkit-user-select:none'
1526
+ // ★ 按下别让焦点跑掉(否则后续命令/输入都落空 ✗)—— 与划词栏同款 ✓
1527
+ tblBar.addEventListener('mousedown', function (e) { e.preventDefault(); e.stopPropagation() })
1528
+ tblBar.appendChild(tblBtn('↑行', '在光标所在行上方插入一行', function () { tblAct('rowBefore') }))
1529
+ tblBar.appendChild(tblBtn('↓行', '在光标所在行下方插入一行', function () { tblAct('rowAfter') }))
1530
+ tblBar.appendChild(tblSep())
1531
+ tblBar.appendChild(tblBtn('←列', '在光标所在列左侧插入一列', function () { tblAct('colBefore') }))
1532
+ tblBar.appendChild(tblBtn('→列', '在光标所在列右侧插入一列', function () { tblAct('colAfter') }))
1533
+ tblBar.appendChild(tblSep())
1534
+ tblBar.appendChild(tblBtn('删行', '删除光标所在行', function () { tblAct('delRow') }))
1535
+ tblBar.appendChild(tblBtn('删列', '删除光标所在列', function () { tblAct('delCol') }))
1536
+ tblBar.appendChild(tblSep())
1537
+ // 单元格对齐(2026-10-15 加)—— markdown 的分隔行能表达 ✓ 所以这个能往返 ✓
1538
+ tblBar.appendChild(tblBtn('⇤', '这一列左对齐', function () { tblAct('alignLeft') }))
1539
+ tblBar.appendChild(tblBtn('↔', '这一列居中对齐', function () { tblAct('alignCenter') }))
1540
+ tblBar.appendChild(tblBtn('⇥', '这一列右对齐', function () { tblAct('alignRight') }))
1541
+ tblBar.appendChild(tblSep())
1542
+ tblBar.appendChild(tblBtn('删表格', '删除整个表格', function () { tblAct('delTable') }))
1543
+ document.body.appendChild(tblBar)
1544
+ return tblBar
1545
+ }
1546
+ function tblSep() {
1547
+ var s = document.createElement('span')
1548
+ s.style.cssText = 'width:1px;height:16px;background:rgba(128,128,128,.22);margin:0 3px;flex:0 0 auto'
1549
+ return s
1550
+ }
1551
+ function tableBarHide() {
1552
+ if (tblBar) tblBar.style.display = 'none'
1553
+ tblBarFor = null
1554
+ }
1555
+ // 光标在哪张表里(不在表格里返回 null ✓)
1556
+ function tableAtCaret() {
1557
+ try {
1558
+ var sel = document.getSelection()
1559
+ if (!sel || !sel.rangeCount) return null
1560
+ var n = sel.getRangeAt(0).startContainer
1561
+ var el = (n && n.nodeType === 3) ? n.parentNode : n
1562
+ if (!el || !el.closest) return null
1563
+ var t = el.closest('table')
1564
+ if (!t) return null
1565
+ var ed = editorEl()
1566
+ if (!ed || !ed.contains(t)) return null
1567
+ return t
1568
+ } catch (_) { return null }
1569
+ }
1570
+ function tableBarSync() {
1571
+ try {
1572
+ if (tableDragging) return
1573
+ var t = tableAtCaret()
1574
+ if (!t) { tableBarHide(); return }
1575
+ var bar = ensureTableBar()
1576
+ var r = t.getBoundingClientRect()
1577
+ bar.style.display = 'flex'
1578
+ var w = bar.offsetWidth || 300
1579
+ var h = bar.offsetHeight || 28
1580
+ var x = Math.max(8, Math.min(r.left, window.innerWidth - w - 8))
1581
+ var y = r.top - h - 6
1582
+ if (y < 8) y = Math.min(r.bottom + 6, window.innerHeight - h - 8)
1583
+ bar.style.left = x + 'px'
1584
+ bar.style.top = y + 'px'
1585
+ tblBarFor = t
1586
+ } catch (_) {}
1587
+ }
1588
+ function tableBarSyncSoon() {
1589
+ if (tblBarRaf) return
1590
+ tblBarRaf = requestAnimationFrame(function () { tblBarRaf = 0; tableBarSync() })
1591
+ }
1592
+
1593
+ // 光标所在的单元格 / 行 / 列号
1594
+ function caretCell() {
1595
+ try {
1596
+ var sel = document.getSelection()
1597
+ if (!sel || !sel.rangeCount) return null
1598
+ var n = sel.getRangeAt(0).startContainer
1599
+ var el = (n && n.nodeType === 3) ? n.parentNode : n
1600
+ var cell = (el && el.closest) ? el.closest('th,td') : null
1601
+ if (!cell) return null
1602
+ var table = cell.closest('table')
1603
+ var tr = cell.parentNode
1604
+ var cells = Array.prototype.slice.call(tr.querySelectorAll('th,td'))
1605
+ var rows = Array.prototype.slice.call(table.querySelectorAll('tr'))
1606
+ return { table: table, cell: cell, tr: tr, rowIdx: rows.indexOf(tr), colIdx: cells.indexOf(cell), rows: rows, cells: cells }
1607
+ } catch (_) { return null }
1608
+ }
1609
+ // 表格的工具条动作(加行/加列/删行/删列/删表格)
1610
+ function tblAct(kind) {
1611
+ var c = caretCell()
1612
+ if (!c) { log('表格工具条:光标不在表格里'); return }
1613
+ var table = c.table
1614
+ if (kind === 'delTable') {
1615
+ table.parentNode.removeChild(table)
1616
+ log('表格:删除整个表格')
1617
+ tableBarHide(); fireInput(); return
1618
+ }
1619
+ if (kind === 'rowBefore' || kind === 'rowAfter') {
1620
+ var nCols = c.cells.length
1621
+ var tr = document.createElement('tr')
1622
+ for (var i = 0; i < nCols; i++) tr.appendChild(document.createElement('td'))
1623
+ if (kind === 'rowBefore') c.tr.parentNode.insertBefore(tr, c.tr)
1624
+ else c.tr.parentNode.insertBefore(tr, c.tr.nextSibling)
1625
+ log('表格:' + (kind === 'rowBefore' ? '上方' : '下方') + '插入一行(' + nCols + ' 列)')
1626
+ } else if (kind === 'colBefore' || kind === 'colAfter') {
1627
+ var at = c.colIdx + (kind === 'colAfter' ? 1 : 0)
1628
+ c.rows.forEach(function (r, ri) {
1629
+ var isHead = (ri === 0)
1630
+ var nc = document.createElement(isHead ? 'th' : 'td')
1631
+ var ref = r.querySelectorAll('th,td')[at] || null
1632
+ if (ref) r.insertBefore(nc, ref)
1633
+ else r.appendChild(nc)
1634
+ })
1635
+ // colgroup 也要跟着加一列 ✓(否则列宽会串位 ✗)
1636
+ var cg = table.querySelector('colgroup')
1637
+ if (cg) {
1638
+ var newCol = document.createElement('col')
1639
+ var cols = cg.querySelectorAll('col')
1640
+ var refCol = cols[at] || null
1641
+ if (refCol) cg.insertBefore(newCol, refCol)
1642
+ else cg.appendChild(newCol)
1643
+ }
1644
+ log('表格:' + (kind === 'colBefore' ? '左侧' : '右侧') + '插入一列(第 ' + (at + 1) + ' 列)')
1645
+ } else if (kind === 'delRow') {
1646
+ if (c.rows.length <= 1) { toastOn('只剩一行了,删不了'); return }
1647
+ c.tr.parentNode.removeChild(c.tr)
1648
+ log('表格:删除第 ' + (c.rowIdx + 1) + ' 行')
1649
+ } else if (kind === 'delCol') {
1650
+ if (c.cells.length <= 1) { toastOn('只剩一列了,删不了'); return }
1651
+ c.rows.forEach(function (r) {
1652
+ var cs = r.querySelectorAll('th,td')
1653
+ if (cs[c.colIdx]) cs[c.colIdx].parentNode.removeChild(cs[c.colIdx])
1654
+ })
1655
+ var cg2 = table.querySelector('colgroup')
1656
+ if (cg2) {
1657
+ var cs2 = cg2.querySelectorAll('col')
1658
+ if (cs2[c.colIdx]) cg2.removeChild(cs2[c.colIdx])
1659
+ }
1660
+ log('表格:删除第 ' + (c.colIdx + 1) + ' 列')
1661
+ } else if (kind === 'alignLeft' || kind === 'alignCenter' || kind === 'alignRight') {
1662
+ // 单元格对齐(2026-10-15 加)
1663
+ // 改的是**整列**(成熟产品也是整列 ✓ 一格一格对齐没人用 ✗)
1664
+ // 用 inline style ✓ 因为 htmlToMd 就是从这里读回 markdown 的 :---: 语法 ✓
1665
+ // ⚠️ 合并已有 style 而不是整体覆盖 ✗(别把别的样式冲掉 ✓)
1666
+ var al2 = kind === 'alignLeft' ? 'left' : (kind === 'alignCenter' ? 'center' : 'right')
1667
+ c.rows.forEach(function (r) {
1668
+ var cs = r.querySelectorAll('th,td')
1669
+ var cell = cs[c.colIdx]
1670
+ if (!cell) return
1671
+ var old = String(cell.getAttribute('style') || '')
1672
+ old = old.replace(/text-align:\s*(left|center|right);?\s*/g, '').replace(/;\s*$/, '')
1673
+ var merged = old ? (old.replace(/;\s*$/, '') + ';text-align:' + al2) : ('text-align:' + al2)
1674
+ cell.setAttribute('style', merged)
1675
+ })
1676
+ log('表格:第 ' + (c.colIdx + 1) + ' 列对齐 → ' + al2)
1677
+ }
1678
+ var ed = editorEl()
1679
+ if (ed) { try { ed.focus() } catch (_) {} }
1680
+ fireInput()
1681
+ tableBarSyncSoon()
1682
+ }
1683
+
1684
+ // ─── 光标位置(按"字符偏移"记 ✓ 因为重设正文后节点全是新的 ✗)──────
1685
+ // 2026-10-15 主人报「Ctrl+Z 会丢失焦点,需要再点一下话布」:
1686
+ // 撤销 → renderSeq +1 → React 重设 innerHTML ✗ → 节点被换掉 ✗
1687
+ // → 焦点与光标一起没了 ✓(成熟软件重设内容时都必须自己接住光标 ✓)
1688
+ function caretOffset(el) {
1689
+ try {
1690
+ var sel = document.getSelection()
1691
+ if (!el || !sel || !sel.rangeCount) return -1
1692
+ var r = sel.getRangeAt(0)
1693
+ if (!el.contains(r.startContainer)) return -1
1694
+ var walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null)
1695
+ var n, off = 0
1696
+ while ((n = walker.nextNode())) {
1697
+ if (n === r.startContainer) return off + r.startOffset
1698
+ off += n.nodeValue.length
1699
+ }
1700
+ return -1
1701
+ } catch (_) { return -1 }
1702
+ }
1703
+ function setCaretOffset(el, off) {
1704
+ try {
1705
+ if (!el) return
1706
+ var walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null)
1707
+ var n, acc = 0
1708
+ while ((n = walker.nextNode())) {
1709
+ var len = n.nodeValue.length
1710
+ if (off >= 0 && acc + len >= off) {
1711
+ var r = document.createRange()
1712
+ r.setStart(n, Math.max(0, Math.min(len, off - acc)))
1713
+ r.collapse(true)
1714
+ var s = document.getSelection()
1715
+ s.removeAllRanges(); s.addRange(r)
1716
+ return
1717
+ }
1718
+ acc += len
1719
+ }
1720
+ // off 落在末尾之后 / 为 -1 → 放到最后 ✓
1721
+ var r2 = document.createRange()
1722
+ r2.selectNodeContents(el)
1723
+ r2.collapse(false)
1724
+ var s2 = document.getSelection()
1725
+ s2.removeAllRanges(); s2.addRange(r2)
1726
+ } catch (_) {}
1727
+ }
1728
+
1729
+ // ★★ 编辑状态存在**组件外面**(2026-10-15 根治「保存后闪一下」)★★
1730
+ // 日志证据:宿主在**文件变化时会把我们的正文组件卸载再重装** ✗
1731
+ // 19:51:18.258 自动保存 ok(文件变了)
1732
+ // 19:51:19.866 重设正文(实例#2 …) ← 1.6 秒后换了个新实例 ✗
1733
+ // 19:51:23.929 undo ok(文件又变了)
1734
+ // 19:51:23.952 重设正文(实例#3 …) ← 又换了一个 ✗
1735
+ // 重挂载 = 元素全新 = 焦点/光标/滚动全没 ✓ 就是主人说的"像刷新了一下" ✓
1736
+ //
1737
+ // 成熟编辑器(VS Code / Typora)都这么做:**编辑状态不放在组件里** ✓
1738
+ // 所以这里把"最后已知的焦点与光标"放在模块级 ✓ 重挂载后自己接回来 ✓
1739
+ // —— 组件可以被重建 ✓ 但用户**感觉不到** ✓
1740
+ var liveEditState = { hadFocus: false, offset: -1, scroll: 0, at: 0 }
1741
+ // ─── 大文档保护 + rAF 节流(2026-10-15 卡死事故的修 ✓)────────────
1742
+ // 事故:主人开着《创世沙盒-需求文档-v5.6》(14.5 万字节)→ 话布把 DSH 卡死 ✗
1743
+ // 原因链:
1744
+ // ① caretOffset 是**全文 TreeWalker** ✗ —— 代价 O(文本节点数) ✓
1745
+ // ② 它挂在**每次 selectionchange** 上 ✗ —— 打字时每个字都触发 ✓
1746
+ // → 每敲一个字全文遍历一遍 ✓ → 十几万字的文档必然卡死 ✓
1747
+ // 修:① rAF 节流(一帧最多一次 ✓)② 超大文档干脆不算偏移 ✗
1748
+ // (宁可光标位置不精确,也绝不能卡 ✓)
1749
+ var LIVE_STATE_MAX_CHARS = 60000
1750
+ var liveStateRaf = 0
1751
+ function noteLiveEditStateSoon() {
1752
+ if (liveStateRaf) return
1753
+ try {
1754
+ liveStateRaf = (window.requestAnimationFrame || function (fn) { return setTimeout(fn, 16) })(function () {
1755
+ liveStateRaf = 0
1756
+ noteLiveEditState()
1757
+ })
1758
+ } catch (_) { liveStateRaf = 0 }
1759
+ }
1760
+ function noteLiveEditState() {
1761
+ try {
1762
+ var ed = liveEditor
1763
+ if (!ed) return
1764
+ // 大小只量一次(每个元素一次 textContent ✓ 之后走缓存 ✓)
1765
+ if (!ed.__ccvSizeChecked) {
1766
+ ed.__ccvSizeChecked = true
1767
+ try { ed.__ccvSize = (ed.textContent || '').length } catch (_) { ed.__ccvSize = 0 }
1768
+ }
1769
+ var tooBig = (ed.__ccvSize || 0) > LIVE_STATE_MAX_CHARS
1770
+ liveEditState = {
1771
+ hadFocus: document.activeElement === ed,
1772
+ offset: tooBig ? liveEditState.offset : caretOffset(ed),
1773
+ scroll: (function () { try { var sb = ccvScrollBox(ed); return sb ? sb.scrollTop : 0 } catch (_) { return 0 } })(),
1774
+ at: Date.now(),
1775
+ }
1776
+ } catch (_) {}
1777
+ }
1778
+
1779
+ // ═══ 目录 / 大纲(2026-10-24)══════════════════════════════════════
1780
+ //
1781
+ // 主人要求:「改成「左目录 + 右正文」」
1782
+ //
1783
+ // 事实(读代码确认):
1784
+ // · 正文根元素**自己就是滚动容器**(boxStyle: height100% + overflow:auto ✓)
1785
+ // · 所以目录必须是**另一个盒子** ✓ 不能塞进正文里 ✗(一塞就脏 → 触发保存)
1786
+ // · 宿主**每次保存都会重建这个组件** ✗ → 开合状态必须放模块级 ✓
1787
+ //
1788
+ // 铁律(这片地雷多,逐条写死):
1789
+ // ① 外层容器形状**永远一样** ✓ 只让目录这个孩子出现/消失 ✓
1790
+ // ("开目录才包一层"会让正文重挂载 → 丢焦点 ✗)
1791
+ // ② 扫标题只读 DOM ✓ 一个字符都不写 ✓
1792
+ // ③ 滚动高亮:rAF 节流 + 只读位置 ✓ 不做全文遍历 ✓
1793
+ // ★ 三态(2026-10-28 主人:「手机上竖屏还是默认开启目录,屏幕太窄,要默认收起」)★
1794
+ // null = 自动:宽 ≥ 高(宽屏/桌面/横屏)→ 展开;否则(窄屏/竖屏手机)→ 收起
1795
+ // true / false = 用户手动开/关过,此后不再自动干预 ✓
1796
+ var navOpen = null
1797
+ var NAV_W = 220 // 目录栏宽度(停靠模式,2026-10-27 由 260 收窄)
1798
+ var NAV_W_OVER = 190 // 浮层模式宽度(2026-10-27 由 208 收窄)
1799
+ // ★ 目录样式表(照豆包文档的大纲样式,2026-10-24)★
1800
+ // 豆包那版的特征:
1801
+ // · 左侧常驻、**没有边框**(靠留白分隔 ✓)
1802
+ // · 顶部一行:收起按钮 + 文档标题(粗体)
1803
+ // · 一级标题粗、二级缩进、三级更小更淡 ✓
1804
+ // · 当前所在那一项**有底色 + 主色**(截图里"2. 目标用户"那样 ✓)
1805
+ // · 悬停有浅底色 ✓
1806
+ // · 自己的**细滚动条**(不是系统粗条 ✓)
1807
+ // ⚠️ 用 CSS class 而不是全内联 ✗ —— 内联写不了 :hover / ::-webkit-scrollbar ✓
1808
+ function ensureTocCss() {
1809
+ try {
1810
+ if (document.getElementById('ccv-toc-style')) return
1811
+ var el = document.createElement('style')
1812
+ el.id = 'ccv-toc-style'
1813
+ el.textContent = ''
1814
+ // ★ 2026-10-24 第二轮(主人四条意见)★
1815
+ // ① 顶部标题和第一条重复 ✗ → 顶部用了第一个 h1 时,列表里**不再重复那一条** ✓
1816
+ // ② 「有边框」✗ → 其实那是**滚动条槽**(scrollbar-gutter:stable 常驻占位 ✓)
1817
+ // 去掉 gutter ✓ 滚动条改成**平时透明、悬停才显形** ✓ 就不像一条边线了 ✓
1818
+ // ③ 「太紧凑」✗ → 行距/内距都放大 ✓ 一级之间留更多空 ✓
1819
+ // ④ 收起后的入口换成「≡」横线样式 ✓ 不要那个方块按钮 ✓
1820
+ + '.ccv-toc{height:100%;box-sizing:border-box;overflow-y:auto;overflow-x:hidden;'
1821
+ + 'padding:16px 12px 28px;font-size:13px;line-height:1.72}'
1822
+ + '.ccv-toc::-webkit-scrollbar{width:6px}'
1823
+ + '.ccv-toc::-webkit-scrollbar-track{background:transparent}'
1824
+ + '.ccv-toc::-webkit-scrollbar-thumb{background:transparent;border-radius:3px}'
1825
+ + '.ccv-toc:hover::-webkit-scrollbar-thumb{background:rgba(128,128,128,.35)}'
1826
+ + '.ccv-toc-head{display:flex;align-items:center;gap:6px;padding:0 2px 14px}'
1827
+ + '.ccv-toc-collapse{width:20px;height:20px;flex:none;border-radius:4px;display:flex;'
1828
+ + 'align-items:center;justify-content:center;cursor:pointer;font-size:13px;'
1829
+ + 'color:var(--dsw-alias-label-tertiary,#6b7280)}'
1830
+ + '.ccv-toc-collapse:hover{color:var(--dsw-alias-label-primary,#1a1a1a);'
1831
+ + 'background:var(--dsw-alias-interactive-bg-hover,rgba(128,128,128,.12))}'
1832
+ + '.ccv-toc-title{font-size:13px;font-weight:700;min-width:0;overflow:hidden;'
1833
+ + 'text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-primary,#1a1a1a)}'
1834
+ // 条目:行距和内距都放大 ✓(原来太挤 ✓)
1835
+ + '.ccv-toc-item{padding:6px 10px;border-radius:6px;cursor:pointer;'
1836
+ + 'color:var(--dsw-alias-label-secondary,#8b8b8b);white-space:nowrap;overflow:hidden;'
1837
+ + 'text-overflow:ellipsis;font-size:13px;line-height:1.6}'
1838
+ + '.ccv-toc-item:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(128,128,128,.12))}'
1839
+ + '.ccv-toc-item.ccv-l1{font-weight:600;color:var(--dsw-alias-label-primary,#1a1a1a);margin-top:14px}'
1840
+ + '.ccv-toc-item.ccv-l1:first-child{margin-top:0}'
1841
+ + '.ccv-toc-item.ccv-l2{padding-left:20px;margin-top:2px}'
1842
+ + '.ccv-toc-item.ccv-l3{padding-left:34px;margin-top:2px;font-size:12.5px}'
1843
+ + '.ccv-toc-item.ccv-l4{padding-left:48px;margin-top:2px;font-size:12.5px}'
1844
+ + '.ccv-toc-item.is-active{color:var(--dsw-alias-brand-primary,#3b82f6);'
1845
+ + 'background:var(--dsw-alias-interactive-bg-hover,rgba(128,128,128,.14));font-weight:600}'
1846
+ + '.ccv-toc-empty{padding:2px 10px;font-size:12px;line-height:1.7;'
1847
+ + 'color:var(--dsw-alias-label-tertiary,#6b7280)}'
1848
+ // 收起后的入口(2026-10-27):**整条左边留白都是热区** ✓
1849
+ // ⚠️ 不能只做一个小图标 ✗ 那样只有 18px 能触发 ✓ 很难瞄 ✓
1850
+ // 三条横线仍在最上面当视觉提示 ✓
1851
+ + '.ccv-toc-reopen{position:absolute;left:0;top:0;bottom:0;width:20px;z-index:39;'
1852
+ + 'display:flex;flex-direction:column;align-items:center;gap:3px;'
1853
+ + 'padding-top:14px;box-sizing:border-box;cursor:default;opacity:.4}'
1854
+ + '.ccv-toc-reopen:hover{opacity:1}'
1855
+ + '.ccv-toc-reopen i{display:block;width:13px;height:1px;background:'
1856
+ + 'var(--dsw-alias-label-secondary,#8b8b8b);border-radius:1px}'
1857
+ document.head.appendChild(el)
1858
+ } catch (_) {}
1859
+ }
1860
+ // 工具栏(Actions)和正文(Body)是**两个独立组件** ✗
1861
+ // → 用一个极小的订阅表把它们连起来 ✓(比状态库轻得多 ✓)
1862
+ var navSubs = []
1863
+ function navEmit() {
1864
+ for (var i = 0; i < navSubs.length; i++) { try { navSubs[i]() } catch (_) {} }
1865
+ }
1866
+ function navToggle() {
1867
+ navOpen = !navOpen
1868
+ try { log('目录:' + (navOpen ? '打开' : '关闭')) } catch (_) {}
1869
+ navEmit()
1870
+ }
1871
+ // 找**真正的滚动容器**:可能是元素自己,也可能是某个祖先
1872
+ // ⚠️ 原来两处直接写 ed.parentElement.scrollTop ✗
1873
+ // (正文自己才是滚动的那个 ✓ 所以一直取到 0 ✓ 顺手修对)
1874
+ function ccvScrollBox(el) {
1875
+ try {
1876
+ if (!el) return null
1877
+ if (el.scrollHeight > el.clientHeight + 4) return el
1878
+ var p = el.parentElement, n = 0
1879
+ while (p && n++ < 24) {
1880
+ var st = null
1881
+ try { st = window.getComputedStyle(p) } catch (_) {}
1882
+ var oy = st ? st.overflowY : ''
1883
+ if ((oy === 'auto' || oy === 'scroll') && p.scrollHeight > p.clientHeight + 4) return p
1884
+ p = p.parentElement
1885
+ }
1886
+ } catch (_) {}
1887
+ return null
1888
+ }
1889
+ // 扫正文里的标题 → 大纲 ✓(只读 ✓)
1890
+ function buildOutline(ed) {
1891
+ var out = []
1892
+ try {
1893
+ if (!ed) return out
1894
+ var hs = ed.querySelectorAll('h1,h2,h3,h4,h5,h6')
1895
+ for (var i = 0; i < hs.length && i < 400; i++) {
1896
+ var el = hs[i]
1897
+ var txt = (el.textContent || '').replace(/\s+/g, ' ').trim()
1898
+ if (!txt) continue
1899
+ var lv = parseInt(String(el.tagName || 'H1').slice(1), 10) || 1
1900
+ out.push({ lv: lv, text: txt, el: el })
1901
+ }
1902
+ } catch (_) {}
1903
+ return out
1904
+ }
1905
+ // 重算大纲(防抖:打字时别每键重扫 ✗)
1906
+ var tocTimer = 0
1907
+ var tocRefreshFn = null // 由组件挂上来(组件里才知道 setOutline)
1908
+ function tocRefreshSoon() {
1909
+ try {
1910
+ if (!navOpen) return
1911
+ if (tocTimer) return
1912
+ tocTimer = setTimeout(function () {
1913
+ tocTimer = 0
1914
+ try { if (tocRefreshFn) tocRefreshFn() } catch (_) {}
1915
+ }, 400)
1916
+ } catch (_) {}
1917
+ }
1918
+ // 当前高亮:最后一个"顶部已越过视口上沿"的标题 ✓
1919
+ function tocActiveIndex(ed, outline) {
1920
+ try {
1921
+ if (!ed || !outline || !outline.length) return -1
1922
+ var boxTop = ed.getBoundingClientRect().top + 30 // 留一点余量
1923
+ var idx = -1
1924
+ for (var i = 0; i < outline.length; i++) {
1925
+ var r = outline[i].el.getBoundingClientRect()
1926
+ if (r.top <= boxTop) idx = i
1927
+ else break
1928
+ }
1929
+ return idx
1930
+ } catch (_) { return -1 }
1931
+ }
1932
+
1933
+ // ═══ 查找 / 替换(D1 · 2026-10-17)══════════════════════════════════
1934
+ //
1935
+ // 为什么要它:长文档里找一句话只能自己滚着看 ✗(14 万字那篇尤其痛苦 ✓)
1936
+ //
1937
+ // ★★ 设计上守住一条:**绝不碰编辑区地基** ✗ ★★
1938
+ // · 查找 = **选中匹配** ✓(用 DOM Selection ✓ 一个字符都不改 ✓)+ 滚到可见 ✓
1939
+ // —— 不做高亮 ✗(改 DOM 就会弄脏内容 ✓ 会把文档标成"有改动"✗✗)
1940
+ // · 替换 = execCommand('insertText') ✓(和工具栏同一条路 ✓
1941
+ // 它会触发正常 input 事件 ✓ → 脏标记/草稿/自动保存全都自动接上 ✓✓)
1942
+ // · 界面 = **固定定位的浮层** ✓(像表格工具条那样 ✓)
1943
+ // —— **不插进 React 的 DOM 树** ✗(插进去就是又去碰宿主的地盘 ✓)
1944
+ // · 不碰:保存路径 ✓ 渲染路径 ✓ 撤销 ✓ DOM 归属 ✓
1945
+ var findBar = null
1946
+ var findWatchTimer = null // "查找条开着时每秒看一眼"的定时器(关了立刻清 ✓)
1947
+ var findState = { q: '', rep: '', idx: -1, count: 0 }
1948
+ var findForPath = ''
1949
+
1950
+ function ensureFindBar() {
1951
+ if (findBar && findBar.parentNode) return findBar
1952
+ findBar = document.createElement('div')
1953
+ findBar.setAttribute('data-ccv-findbar', '1')
1954
+ findBar.style.cssText = 'position:fixed;z-index:2147483004;display:none;align-items:center;gap:4px;'
1955
+ + 'width:max-content;padding:5px 7px;border-radius:8px;font-size:13px;'
1956
+ + 'background:var(--dsw-alias-bg-layer-1,#fff);'
1957
+ + 'color:var(--dsw-alias-label-primary,#1a1a1a);'
1958
+ + 'border:1px solid var(--dsw-alias-border-l1,rgba(128,128,128,.3));'
1959
+ + 'box-shadow:0 6px 18px rgba(0,0,0,.18);user-select:none;-webkit-user-select:none'
1960
+ var mkInput = function (ph, w) {
1961
+ var i = document.createElement('input')
1962
+ i.type = 'text'
1963
+ i.placeholder = ph
1964
+ i.style.cssText = 'width:' + w + 'px;padding:3px 6px;font-size:13px;border-radius:5px;'
1965
+ + 'background:var(--dsw-alias-bg-layer-2,rgba(128,128,128,.08));'
1966
+ + 'color:var(--dsw-alias-label-primary,#1a1a1a);'
1967
+ + 'border:1px solid var(--dsw-alias-border-l1,rgba(128,128,128,.3));outline:none'
1968
+ // 点输入框不能把编辑区选区弄丢 ✓ → 先把选区存下来 ✓
1969
+ i.addEventListener('focus', function () { try { saveSelection() } catch (_) {} })
1970
+ return i
1971
+ }
1972
+ var q = mkInput('查找', 130); q.setAttribute('data-ccv-find-q', '1')
1973
+ var rep = mkInput('替换为', 130); rep.setAttribute('data-ccv-find-rep', '1')
1974
+ var cnt = document.createElement('span')
1975
+ cnt.setAttribute('data-ccv-find-count', '1')
1976
+ cnt.style.cssText = 'min-width:52px;text-align:center;opacity:.75;font-variant-numeric:tabular-nums'
1977
+ var mkBtn = function (label, title, fn) {
1978
+ var b = document.createElement('button')
1979
+ b.type = 'button'
1980
+ b.textContent = label
1981
+ b.title = title
1982
+ b.style.cssText = 'padding:3px 8px;font-size:12.5px;border-radius:5px;cursor:pointer;white-space:nowrap;'
1983
+ + 'background:var(--dsw-alias-bg-layer-2,rgba(128,128,128,.08));'
1984
+ + 'color:var(--dsw-alias-label-primary,#1a1a1a);'
1985
+ + 'border:1px solid var(--dsw-alias-border-l1,rgba(128,128,128,.3))'
1986
+ b.addEventListener('mousedown', function (e) { e.preventDefault() }) // 别让焦点跑掉 ✓
1987
+ b.addEventListener('click', function (e) { e.preventDefault(); fn() })
1988
+ return b
1989
+ }
1990
+ // 回车 = 下一处 ✓ Shift+回车 = 上一处 ✓ Esc = 关 ✓
1991
+ // ⚠️ 2026-10-17 主人报:「下一处按钮显示可以按 enter,但我按了没有调下一步
1992
+ // 而是在话布上执行换行」✗
1993
+ // 原因:点完「下一处」后,我在编辑区里设了选区 ✓
1994
+ // 浏览器就把焦点挪进编辑区了 ✗ → 你按 Enter 落到编辑区 → 换行 ✓
1995
+ // 修:① 每次跳完**把焦点抢回查找框** ✓(成熟软件都这样 ✓
1996
+ // VS Code 的查找框也一直握着焦点 ✓)
1997
+ // ② 查找框开着时,**在文档级**也接 Enter/Shift+Enter/Esc ✓
1998
+ // (这样不管焦点跑哪儿都能用 ✓ 而且只在这个框开着时才拦 ✓)
1999
+ q.addEventListener('keydown', function (e) {
2000
+ // ⚠️ 2026-10-17 主人说「enter的快捷键还在」→ **整个去掉** ✗
2001
+ // 他的意思很明确:不要这个快捷键 ✓
2002
+ // 去掉后:编辑区里 Enter 就是正常换行 ✓ 查找框里也不拦 ✓
2003
+ // 跳转只用 ↑ ↓ 按钮 ✓
2004
+ if (e.key === 'Escape') { e.preventDefault(); findHide() }
2005
+ })
2006
+ q.addEventListener('input', function () { findRecollect() })
2007
+ rep.addEventListener('keydown', function (e) {
2008
+ if (e.key === 'Enter') { e.preventDefault(); findReplaceOne() }
2009
+ else if (e.key === 'Escape') { e.preventDefault(); findHide() }
2010
+ })
2011
+ findBar.appendChild(q)
2012
+ findBar.appendChild(cnt)
2013
+ findBar.appendChild(mkBtn('↑', '上一处(Shift+Enter)', function () { findGo(-1) }))
2014
+ findBar.appendChild(mkBtn('↓', '下一处(Enter)', function () { findGo(1) }))
2015
+ findBar.appendChild(rep)
2016
+ findBar.appendChild(mkBtn('替换', '替换这一处', function () { findReplaceOne() }))
2017
+ findBar.appendChild(mkBtn('全部替换', '替换全部(最多 500 处)', function () { findReplaceAll() }))
2018
+ findBar.appendChild(mkBtn('×', '关闭(Esc)', function () { findHide() }))
2019
+ document.body.appendChild(findBar)
2020
+ return findBar
2021
+ }
2022
+
2023
+ // 收集所有匹配(**只读** ✓ 不改一个字符 ✓)
2024
+ // 返回 [{node, start, end}] —— 按文档顺序 ✓
2025
+ function findCollect(ed, q) {
2026
+ var out = []
2027
+ if (!ed || !q) return out
2028
+ try {
2029
+ var walker = document.createTreeWalker(ed, NodeFilter.SHOW_TEXT, null)
2030
+ var n
2031
+ while ((n = walker.nextNode())) {
2032
+ var t = n.nodeValue || ''
2033
+ if (!t) continue
2034
+ var low = t.toLowerCase(), lq = q.toLowerCase(), from = 0, at
2035
+ while ((at = low.indexOf(lq, from)) >= 0) {
2036
+ out.push({ node: n, start: at, end: at + q.length })
2037
+ from = at + Math.max(1, q.length)
2038
+ if (out.length > 2000) return out // 上限(防超大文档卡住 ✓)
2039
+ }
2040
+ }
2041
+ } catch (_) {}
2042
+ return out
2043
+ }
2044
+ // 一个文本节点里的某一点,在**整篇正文**里排第几个字符 ✓(诊断用 ✓)
2045
+ // 用来判断"两处匹配是不是落在同一个地方"✗
2046
+ function findOffsetOf(ed, node, off) {
2047
+ try {
2048
+ if (!ed) return -1
2049
+ var walker = document.createTreeWalker(ed, NodeFilter.SHOW_TEXT, null)
2050
+ var n, acc = 0
2051
+ while ((n = walker.nextNode())) {
2052
+ if (n === node) return acc + off
2053
+ acc += (n.nodeValue || '').length
2054
+ }
2055
+ } catch (_) {}
2056
+ return -1
2057
+ }
2058
+ // ★★ 高亮改成【浮层色块】(2026-10-17 第三次修,这次一定能看见 ✓)★★
2059
+ //
2060
+ // 主人原话:「现在还是没有高亮,以前是选中文字,你之前是不是改掉改不回来了」
2061
+ // —— 他说对了 ✗ 我把"选中"那条路弄没了:
2062
+ // 为了修 Enter 那个问题 ✓ 我让跳完**把焦点抢回查找框** ✗
2063
+ // → 编辑区的选区变成**非激活** ✓ → 浏览器干脆不画它 ✗✓
2064
+ // → 加上 CSS Custom Highlight API 在他浏览器上不支持 ✗
2065
+ // → 结果:**什么都看不见** ✓✓
2066
+ //
2067
+ // 这次用**跟焦点无关**的办法 ✓:在正文上面盖一层**色块** ✓
2068
+ // · 一个 `position:fixed; pointer-events:none` 的浮层容器 ✓
2069
+ // · 每个匹配 → `range.getClientRects()` → 盖几个小块 ✓
2070
+ // · 当前那一处换一种颜色 ✓
2071
+ // · **一个字符都不改正文** ✓✓ 不脏 ✓ 不触发保存 ✓ 不碰地基 ✓
2072
+ // · 浮层 `pointer-events:none` ✓ → 不挡点击 ✓
2073
+ // · 只画**视口里**的 ✓(最多 200 块 ✓ 大文档也不卡 ✓)
2074
+ // · 滚动 / 改字 / 定时检查时**重画** ✓(位置跟着走 ✓)
2075
+ var FIND_BOX_MAX = 200
2076
+ var findLayer = null
2077
+ function ensureFindLayer() {
2078
+ if (findLayer && findLayer.parentNode) return findLayer
2079
+ findLayer = document.createElement('div')
2080
+ findLayer.setAttribute('data-ccv-findlayer', '1')
2081
+ findLayer.style.cssText = 'position:fixed;left:0;top:0;right:0;bottom:0;'
2082
+ + 'pointer-events:none;z-index:2147482999;overflow:hidden'
2083
+ try { document.body.appendChild(findLayer) } catch (_) {}
2084
+ return findLayer
2085
+ }
2086
+ function findClearHighlight() {
2087
+ try {
2088
+ if (findLayer) findLayer.textContent = ''
2089
+ } catch (_) {}
2090
+ try {
2091
+ if (window.CSS && window.CSS.highlights) {
2092
+ window.CSS.highlights.delete('ccv-find')
2093
+ window.CSS.highlights.delete('ccv-find-cur')
2094
+ }
2095
+ } catch (_) {}
2096
+ }
2097
+ // 画:把每个匹配的 client rects 盖成小块 ✓
2098
+ function findPaintHighlight(ms, curIdx) {
2099
+ try {
2100
+ if (!ms || !ms.length) { findClearHighlight(); return }
2101
+ var layer = ensureFindLayer()
2102
+ var vh = window.innerHeight || 800
2103
+ var vw = window.innerWidth || 1200
2104
+ var frag = document.createDocumentFragment()
2105
+ var painted = 0
2106
+ for (var i = 0; i < ms.length && painted < FIND_BOX_MAX; i++) {
2107
+ var m = ms[i]
2108
+ var rg = null
2109
+ try {
2110
+ rg = document.createRange()
2111
+ rg.setStart(m.node, m.start)
2112
+ rg.setEnd(m.node, m.end)
2113
+ } catch (_) { continue }
2114
+ var rects = null
2115
+ try { rects = rg.getClientRects() } catch (_) {}
2116
+ if (!rects || !rects.length) continue
2117
+ var isCur = (i === curIdx)
2118
+ for (var k = 0; k < rects.length && painted < FIND_BOX_MAX; k++) {
2119
+ var rc = rects[k]
2120
+ // 只画视口里的 ✓(大文档不卡 ✓)
2121
+ if (rc.bottom < 0 || rc.top > vh || rc.right < 0 || rc.left > vw) continue
2122
+ if (rc.width < 1 || rc.height < 1) continue
2123
+ var bx = document.createElement('div')
2124
+ bx.style.cssText = 'position:fixed;pointer-events:none;border-radius:2px;'
2125
+ + 'left:' + Math.round(rc.left) + 'px;top:' + Math.round(rc.top) + 'px;'
2126
+ + 'width:' + Math.round(rc.width) + 'px;height:' + Math.round(rc.height) + 'px;'
2127
+ + 'background:' + (isCur ? 'rgba(255,140,0,.55)' : 'rgba(255,205,0,.42)')
2128
+ frag.appendChild(bx)
2129
+ painted++
2130
+ }
2131
+ }
2132
+ layer.textContent = ''
2133
+ layer.appendChild(frag)
2134
+ if (findHlPainted !== painted) {
2135
+ findHlPainted = painted
2136
+ log('查找高亮:已画 ' + painted + ' 块色块 ✓(浮层法 ✓ 与焦点无关 ✓ 不改正文 ✓)'
2137
+ + '|匹配 ' + ms.length + ' 处')
2138
+ }
2139
+ } catch (err) {
2140
+ log('查找高亮出错: ' + (err && err.message))
2141
+ }
2142
+ }
2143
+ var findHlPainted = -1
2144
+
2145
+ function findEd() { return editorEl() }
2146
+ function findShow(path) {
2147
+ try {
2148
+ var bar = ensureFindBar()
2149
+ findForPath = path || findForPath
2150
+ bar.style.display = 'inline-flex'
2151
+ findPlace()
2152
+ // 拿编辑区里选中的文字当默认查找词 ✓(像成熟软件那样 ✓)
2153
+ try {
2154
+ var sel = String((document.getSelection() || {}).toString() || '').trim()
2155
+ var qi = bar.querySelector('[data-ccv-find-q]')
2156
+ if (sel && sel.length <= 80 && qi && !qi.value) qi.value = sel
2157
+ } catch (_) {}
2158
+ findRecollect()
2159
+ try { var qi2 = bar.querySelector('[data-ccv-find-q]'); if (qi2) { qi2.focus(); qi2.select() } } catch (_) {}
2160
+ // ★ 开了之后:跟着滚动走 ✓ + 每秒看一次"编辑区还在不在"(2026-10-17)★
2161
+ // ① 跟着滚动:不跟的话它就一直钉在窗口角上 ✗(主人截图里那样 ✓)
2162
+ // ② 每秒看一次:关侧栏时**卸载和 resize 都不一定触发** ✗
2163
+ // 我上一版只靠那两条 → 检查根本没跑过 ✓ 所以"还是不会消失"✓
2164
+ // ⚠️ 这个定时器**只在查找条开着时才存在** ✓ 关了立刻清掉 ✓
2165
+ try { window.addEventListener('scroll', findPlace, true) } catch (_) {}
2166
+ try {
2167
+ if (findWatchTimer) clearInterval(findWatchTimer)
2168
+ findWatchTimer = setInterval(function () {
2169
+ try {
2170
+ if (!findBar || findBar.style.display === 'none') {
2171
+ if (findWatchTimer) { clearInterval(findWatchTimer); findWatchTimer = null }
2172
+ try { window.removeEventListener('scroll', findPlace, true) } catch (_) {}
2173
+ return
2174
+ }
2175
+ var ed = findEd()
2176
+ // ★ 用 findEditorVisible(沿祖先链判 ✓ 而不是只看 isConnected ✗)
2177
+ if (!findEditorVisible()) {
2178
+ log('查找条开着,但编辑区看不见了(侧栏关了 / 切走了)→ 收起 ✓')
2179
+ findHide()
2180
+ return
2181
+ }
2182
+ findPlace()
2183
+ } catch (_) {}
2184
+ }, 300)
2185
+ } catch (_) {}
2186
+ log('查找条已打开(选中匹配但**不改内容** ✓ 零风险 ✓)')
2187
+ } catch (err) { log('打开查找条失败: ' + (err && err.message)) }
2188
+ }
2189
+ // 把查找条放到"编辑区可见区域的顶部" ✓ 并且**夹在视口里** ✓
2190
+ // ⚠️ 2026-10-17 主人截图:它贴在窗口左上角 ✗
2191
+ // 原因:文档滚动后编辑区的 rect.top 是**负数** ✗ 被我夹成了 8 ✓
2192
+ // → 现在:先算编辑区顶部 ✓ 再夹进 [8, 视口高-条高-8] ✓
2193
+ // 这样它**永远看得见、也永远压在正文上方** ✓ 滚动时跟着走 ✓
2194
+ function findPlace() {
2195
+ try {
2196
+ var bar = findBar
2197
+ if (!bar || bar.style.display === 'none') return
2198
+ var ed = findEd()
2199
+ var r = (ed && ed.getBoundingClientRect) ? ed.getBoundingClientRect() : null
2200
+ var vh = window.innerHeight || 800
2201
+ var vw = window.innerWidth || 1200
2202
+ var bh = bar.offsetHeight || 34
2203
+ var bw = bar.offsetWidth || 420
2204
+ var top = r ? (r.top + 6) : 70
2205
+ // 夹进视口 ✓(滚动后 r.top 可能是负的 ✓ 夹完就贴在视口顶部 ✓ 仍然可见 ✓)
2206
+ if (top < 8) top = 8
2207
+ if (top > vh - bh - 8) top = Math.max(8, vh - bh - 8)
2208
+ var left = r ? Math.min(r.right - bw - 12, r.left + 18) : (vw - bw - 24)
2209
+ if (!(left > 8)) left = 8
2210
+ if (left > vw - bw - 8) left = Math.max(8, vw - bw - 8)
2211
+ bar.style.top = Math.round(top) + 'px'
2212
+ bar.style.left = Math.round(left) + 'px'
2213
+ // ★ 色块要跟着滚动走 ✓(否则滚动后色块留在原地 ✗)
2214
+ // ⚠️ 滚动事件很密 ✗ → rAF 节流(一帧最多重画一次 ✓)
2215
+ findRepaintSoon()
2216
+ } catch (_) {}
2217
+ }
2218
+ // 重画色块(rAF 节流 ✓ 滚动/改字/定时检查都会调它 ✓)
2219
+ var findRepaintRaf = 0
2220
+ function findRepaintSoon() {
2221
+ try {
2222
+ if (!findBar || findBar.style.display === 'none') return
2223
+ if (findRepaintRaf) return
2224
+ findRepaintRaf = (window.requestAnimationFrame || function (fn) { return setTimeout(fn, 16) })(function () {
2225
+ findRepaintRaf = 0
2226
+ try { findPaintHighlight(findState.matches || [], findState.idx) } catch (_) {}
2227
+ })
2228
+ } catch (_) {}
2229
+ }
2230
+ function findHide() {
2231
+ try {
2232
+ if (findBar) findBar.style.display = 'none'
2233
+ // ★ 关掉时把高亮也清掉 ✓(不留痕迹 ✓)
2234
+ findClearHighlight()
2235
+ // ★ 把"跟着滚动"和"每秒看一眼"的钩子也撤掉 ✓(不关就一直在跑 ✗)
2236
+ if (findWatchTimer) { clearInterval(findWatchTimer); findWatchTimer = null }
2237
+ try { window.removeEventListener('scroll', findPlace, true) } catch (_) {}
2238
+ var ed = findEd()
2239
+ if (ed) { try { ed.focus() } catch (_) {} }
2240
+ try { restoreSelection() } catch (_) {}
2241
+ log('查找条已关闭 ✓')
2242
+ } catch (_) {}
2243
+ }
2244
+ function findRecollect() {
2245
+ try {
2246
+ var bar = findBar
2247
+ if (!bar) return
2248
+ var q = (bar.querySelector('[data-ccv-find-q]') || {}).value || ''
2249
+ findState.q = q
2250
+ var ms = findCollect(findEd(), q)
2251
+ findState.count = ms.length
2252
+ findState.matches = ms
2253
+ if (findState.idx >= ms.length) findState.idx = ms.length - 1
2254
+ var cnt = bar.querySelector('[data-ccv-find-count]')
2255
+ if (cnt) cnt.textContent = q ? (ms.length ? ((findState.idx + 1) + '/' + ms.length) : '0 处') : ''
2256
+ log('查找「' + q + '」→ ' + ms.length + ' 处')
2257
+ // ★ 把所有匹配都画上高亮 ✓(不改 DOM ✓ 焦点在查找框里也看得见 ✓)
2258
+ findPaintHighlight(ms, findState.idx)
2259
+ // ★ 诊断(2026-10-17 主人报"只搜到 4 个但显示 8 个")★
2260
+ // 把每一处的位置与上下文打出来 ✓ —— 一眼就能看出是不是"两处落在同一个地方"✗
2261
+ // (不猜 ✓ 让证据说话 ✓ 这是这轮反复吃亏换来的规矩 ✓)
2262
+ try {
2263
+ if (q && ms.length) {
2264
+ for (var di = 0; di < ms.length && di < 20; di++) {
2265
+ var m = ms[di]
2266
+ var p = m.node.parentElement
2267
+ var tag = (p && p.tagName) ? p.tagName.toLowerCase() : '?'
2268
+ var pth = ''
2269
+ try {
2270
+ var a = p
2271
+ while (a && a !== findEd() && pth.length < 60) {
2272
+ pth = (a.tagName ? a.tagName.toLowerCase() : '?') + (a.className ? '.' + String(a.className).split(' ')[0] : '') + '>' + pth
2273
+ a = a.parentElement
2274
+ }
2275
+ } catch (_) {}
2276
+ var txt = String(m.node.nodeValue || '')
2277
+ var win = txt.slice(Math.max(0, m.start - 12), m.end + 12)
2278
+ // 它在整篇里的字符偏移(用来判断是不是同一处 ✓)
2279
+ var off = findOffsetOf(findEd(), m.node, m.start)
2280
+ log(' 第' + (di + 1) + '处:父<' + tag + '> 路径=' + pth
2281
+ + ' 偏移=' + off + ' 上下文=…' + win.replace(/\n/g, '⏎') + '…')
2282
+ }
2283
+ }
2284
+ } catch (_) {}
2285
+ } catch (_) {}
2286
+ }
2287
+ function findGo(dir) {
2288
+ try {
2289
+ var bar = findBar
2290
+ if (!bar) return
2291
+ var ms = findState.matches || []
2292
+ if (!ms.length) { findRecollect(); ms = findState.matches || [] }
2293
+ if (!ms.length) return
2294
+ var i = findState.idx
2295
+ if (i < 0) i = (dir > 0 ? -1 : 0)
2296
+ i = (i + dir + ms.length) % ms.length
2297
+ findState.idx = i
2298
+ var m = ms[i]
2299
+ // ★ 只**选中**它 ✓ 不改内容 ✓
2300
+ var rg = document.createRange()
2301
+ rg.setStart(m.node, m.start)
2302
+ rg.setEnd(m.node, m.end)
2303
+ var sel = document.getSelection()
2304
+ sel.removeAllRanges()
2305
+ sel.addRange(rg)
2306
+ savedRange = rg.cloneRange() // 记下来 ✓ 替换要用 ✓
2307
+ // 滚到可见 ✓
2308
+ try {
2309
+ var p = m.node.parentElement
2310
+ if (p && p.scrollIntoView) p.scrollIntoView({ block: 'center', behavior: 'auto' })
2311
+ } catch (_) {}
2312
+ var cnt = bar.querySelector('[data-ccv-find-count]')
2313
+ if (cnt) cnt.textContent = (i + 1) + '/' + ms.length
2314
+ // ★ 当前那一处换个颜色 ✓("跳到哪了"一眼可见 ✓)
2315
+ findPaintHighlight(ms, i)
2316
+ // ⚠️ 2026-10-17:**不再**把焦点抢回查找框 ✗✗
2317
+ // 原来抢焦点是为了让 Enter 不落到编辑区 ✓
2318
+ // 但它的副作用是:编辑区的选区变成**非激活** ✗ → 浏览器不画它 ✗✓
2319
+ // → 主人看到的"什么都没有"✓ 根因就在这一行 ✓
2320
+ // 现在:高亮由**浮层**负责 ✓ 跟焦点无关 ✓
2321
+ // 所以焦点**留在编辑区**更好 ✓(选区也是激活的 ✓ 双重可见 ✓)
2322
+ // 而且 Enter 已经整个去掉了 ✗ 不存在"落到编辑区"的问题 ✓
2323
+ } catch (err) { log('跳到匹配失败: ' + (err && err.message)) }
2324
+ }
2325
+ // 查找框开着时,在**文档级**接 Enter / Shift+Enter / Esc ✓
2326
+ // (不管焦点跑哪儿都能用 ✓ 而且只在框开着时才拦 ✓ 平时一个字都不影响 ✓)
2327
+ function findGlobalKey(e) {
2328
+ try {
2329
+ if (!findBar || findBar.style.display === 'none') return
2330
+ // ★ 顺手确认编辑区还看得见 ✓(看不见就收掉 ✓ 不做别的 ✓)
2331
+ if (!findEditorVisible()) { findHide(); return }
2332
+ var t = e.target
2333
+ // 已经在查找框里 → 由它自己的监听处理 ✓ 不重复 ✓
2334
+ if (t && findBar.contains(t)) return
2335
+ // ⚠️ 2026-10-17 主人说「enter的快捷键还在」→ **Enter 整个去掉** ✗
2336
+ // 只留 Esc(关查找条 ✓ 这个是他要的 ✓)
2337
+ if (e.key === 'Escape' || e.key === 'Esc') {
2338
+ e.preventDefault(); e.stopPropagation()
2339
+ findHide()
2340
+ }
2341
+ } catch (_) {}
2342
+ }
2343
+ // 编辑区真的没了 → 把查找条收掉 ✓
2344
+ // ⚠️ 2026-10-17 主人报:「搜索窗口在我关了侧栏后还存在」✗
2345
+ // 因为查找条挂在 document.body 上 ✓ 关侧栏时没人收它 ✓
2346
+ // ⚠️ 但不能一卸载就收 ✗ —— 宿主**每次保存都会重建组件** ✓
2347
+ // 那样查找框会在你搜索时突然消失 ✓
2348
+ // → 判据:**等一拍再看编辑区还在不在文档里** ✓
2349
+ // 重挂载(宿主重建)→ 新编辑区还在 ✓ 不收 ✓
2350
+ // 真关闭(侧栏关了)→ 编辑区没了 ✓ 收 ✓
2351
+ // 编辑区现在**真的看得见**吗?(2026-10-17 修主人报的"关侧栏只是从左边跑到右边"✗)
2352
+ // ⚠️ 上一版只看了 `isConnected` + `offsetWidth` ✗ —— **判错了** ✓
2353
+ // 关侧栏时元素**还在 DOM 里、还有宽度** ✓(只是被挪走或被祖先藏起来 ✓)
2354
+ // → 检查通过 → 不收 ✓ 反而布局变了 → findPlace 把它挪到右边 ✗✓
2355
+ // 现在沿**祖先链**看四件事 ✓ 任何一条不满足就算"看不见了" ✓:
2356
+ // ① 还在文档里 ✓ ② 没有祖先 display:none / visibility:hidden ✓
2357
+ // ③ 尺寸没塌(宽高 > 40)✓ ④ 没有整个跑到视口外 ✓
2358
+ function findEditorVisible() {
2359
+ try {
2360
+ var ed = findEd()
2361
+ if (!ed || !ed.isConnected) { findWhy('编辑区不在文档里'); return false }
2362
+ // ★ 先做**便宜**的判断(读一次 rect ✓)再做贵的(走祖先链 ✗)
2363
+ var r = ed.getBoundingClientRect ? ed.getBoundingClientRect() : null
2364
+ if (!r) { findWhy('拿不到 rect'); return false }
2365
+ if (r.width < 40 || r.height < 40) { findWhy('尺寸塌了 ' + Math.round(r.width) + 'x' + Math.round(r.height)); return false }
2366
+ var vh = window.innerHeight || 800
2367
+ var vw = window.innerWidth || 1200
2368
+ if (r.bottom < 0 || r.top > vh || r.right < 0 || r.left > vw) { findWhy('整个跑到视口外'); return false }
2369
+ // 再走祖先链 ✓(只在上面都通过时才做 ✓)
2370
+ var n = ed
2371
+ var guard = 0
2372
+ while (n && n.nodeType === 1 && guard++ < 60) {
2373
+ var cs = null
2374
+ try { cs = window.getComputedStyle(n) } catch (_) {}
2375
+ if (cs) {
2376
+ if (cs.display === 'none' || cs.visibility === 'hidden') {
2377
+ findWhy('祖先 ' + (n.tagName || '?') + ' 是 ' + cs.display + '/' + cs.visibility)
2378
+ return false
2379
+ }
2380
+ }
2381
+ n = n.parentElement
2382
+ }
2383
+ return true
2384
+ } catch (_) { return true } // 判不出来就**不隐藏** ✓(宁可多留一会儿,也别乱收 ✓)
2385
+ }
2386
+ // 为什么判成"看不见"(诊断用 ✓ 只打一次同样的理由 ✓)
2387
+ var findWhySaid = ''
2388
+ function findWhy(why) {
2389
+ try {
2390
+ if (findWhySaid === why) return
2391
+ findWhySaid = why
2392
+ log('查找条:编辑区判成"看不见"了 → ' + why + '(据此收起 ✓)')
2393
+ } catch (_) {}
2394
+ }
2395
+ function findHideIfEditorGone() {
2396
+ try {
2397
+ if (!findBar || findBar.style.display === 'none') return
2398
+ setTimeout(function () {
2399
+ try {
2400
+ if (!findEditorVisible()) {
2401
+ log('编辑区不在了(侧栏关了 / 切走了 / 被藏起来)→ 收起查找条 ✓')
2402
+ findHide()
2403
+ }
2404
+ } catch (_) {}
2405
+ }, 0)
2406
+ } catch (_) {}
2407
+ }
2408
+ // 替换这一处:**走 execCommand** ✓ 与工具栏同一条路 ✓
2409
+ // → 自动触发 input ✓ → 脏标记/草稿/自动保存全都接上 ✓✓
2410
+ function findReplaceOne() {
2411
+ try {
2412
+ var bar = findBar
2413
+ if (!bar) return
2414
+ var q = (bar.querySelector('[data-ccv-find-q]') || {}).value || ''
2415
+ var rp = (bar.querySelector('[data-ccv-find-rep]') || {}).value || ''
2416
+ if (!q) return
2417
+ var ed = findEd()
2418
+ if (!ed) return
2419
+ var ms = findCollect(ed, q)
2420
+ if (!ms.length) { toastOn('没找到「' + q + '」'); return }
2421
+ // 优先替换"当前选中那一处"✓ 没选中就替换第一处 ✓
2422
+ var target = null
2423
+ try {
2424
+ var sel = document.getSelection()
2425
+ if (sel && sel.rangeCount) {
2426
+ var r = sel.getRangeAt(0)
2427
+ for (var k = 0; k < ms.length; k++) {
2428
+ if (ms[k].node === r.startContainer && ms[k].start === r.startOffset) { target = ms[k]; break }
2429
+ }
2430
+ }
2431
+ } catch (_) {}
2432
+ if (!target) target = ms[0]
2433
+ try { ed.focus() } catch (_) {}
2434
+ var rg = document.createRange()
2435
+ rg.setStart(target.node, target.start)
2436
+ rg.setEnd(target.node, target.end)
2437
+ var s2 = document.getSelection()
2438
+ s2.removeAllRanges(); s2.addRange(rg)
2439
+ document.execCommand('insertText', false, rp)
2440
+ log('替换一处:「' + q + '」→「' + rp + '」✓(走 execCommand ✓ 正常 input 已触发 ✓)')
2441
+ findState.idx = -1
2442
+ findRecollect()
2443
+ findGo(1)
2444
+ } catch (err) { log('替换失败: ' + (err && err.message)) }
2445
+ }
2446
+ function findReplaceAll() {
2447
+ try {
2448
+ var bar = findBar
2449
+ if (!bar) return
2450
+ var q = (bar.querySelector('[data-ccv-find-q]') || {}).value || ''
2451
+ var rp = (bar.querySelector('[data-ccv-find-rep]') || {}).value || ''
2452
+ if (!q) return
2453
+ var ed = findEd()
2454
+ if (!ed) return
2455
+ var n = 0
2456
+ for (var guard = 0; guard < 500; guard++) {
2457
+ var ms = findCollect(ed, q)
2458
+ if (!ms.length) break
2459
+ var m = ms[0]
2460
+ try { ed.focus() } catch (_) {}
2461
+ var rg = document.createRange()
2462
+ rg.setStart(m.node, m.start)
2463
+ rg.setEnd(m.node, m.end)
2464
+ var s2 = document.getSelection()
2465
+ s2.removeAllRanges(); s2.addRange(rg)
2466
+ document.execCommand('insertText', false, rp)
2467
+ n++
2468
+ }
2469
+ log('全部替换:' + n + ' 处(上限 500 ✓ 走 execCommand ✓)')
2470
+ toastOn(n ? ('已替换 ' + n + ' 处 ✓') : ('没找到「' + q + '」'))
2471
+ findState.idx = -1
2472
+ findRecollect()
2473
+ } catch (err) { log('全部替换失败: ' + (err && err.message)) }
2474
+ }
2475
+ // 内容变了 → 重新数一遍 + 重画高亮 ✓(否则高亮的 Range 会指到已经不存在的节点上 ✗)
2476
+ // ⚠️ 必须**节流** ✗ —— 打字时每次 input 都会调它 ✓
2477
+ // 而 findCollect 是全文遍历 ✗(正是 2026-10-15 卡死事故那个代价 ✓)
2478
+ // → rAF 节流(一帧最多一次 ✓)而且只在查找条开着时才跑 ✓
2479
+ var findSyncRaf = 0
2480
+ function findSyncOnEdit() {
2481
+ try {
2482
+ if (!findBar || findBar.style.display === 'none') return
2483
+ if (findSyncRaf) return
2484
+ findSyncRaf = (window.requestAnimationFrame || function (fn) { return setTimeout(fn, 16) })(function () {
2485
+ findSyncRaf = 0
2486
+ try { findRecollect() } catch (_) {}
2487
+ })
2488
+ } catch (_) {}
2489
+ }
2490
+
2491
+ // 当前编辑区元素(模块级,Body 挂载时写进来 —— 老面板用的是 editorRef.current ✓)
2492
+ var liveEditor = null
2493
+ function editorEl() { return liveEditor }
2494
+ // 选中文字时那几个按钮该不该亮
2495
+ function updateSelFormats() {
2496
+ var out = { bold: false, italic: false, strikeThrough: false, underline: false, mark: false }
2497
+ try {
2498
+ out.bold = document.queryCommandState('bold')
2499
+ out.italic = document.queryCommandState('italic')
2500
+ out.strikeThrough = document.queryCommandState('strikeThrough')
2501
+ out.underline = document.queryCommandState('underline')
2502
+ } catch (_) {}
2503
+ try {
2504
+ var sel = document.getSelection()
2505
+ if (sel && sel.rangeCount) {
2506
+ var n = sel.getRangeAt(0).startContainer
2507
+ var el = (n && n.nodeType === 3) ? n.parentNode : n
2508
+ out.mark = !!(el && el.closest && el.closest('mark'))
2509
+ }
2510
+ } catch (_) {}
2511
+ return out
2512
+ }
2513
+
2514
+ var selbar = null
2515
+ var savedRange = null
2516
+ var savedText = ''
2517
+ var listenersOn = false
2518
+ // 当前这篇文档的真实路径(建编辑区时交给 host,它按路径找回主文档)
2519
+ var lastAbsPath = ''
2520
+ // 路径来源诊断:只在路径变了时打一行 ✓(见 Body 里那段注释 ✓)
2521
+ var pathSaid = '\u0000未打过'
2522
+
2523
+ function insideBody(node) {
2524
+ var el = (node && node.nodeType === 3) ? node.parentNode : node
2525
+ try { return !!(el && el.closest && el.closest('[data-ccv-md-body="1"]')) } catch (_) { return false }
2526
+ }
2527
+
2528
+ function ensureSelbar() {
2529
+ if (selbar && selbar.parentNode) return selbar
2530
+ selbar = document.createElement('div')
2531
+ selbar.setAttribute('data-ccv-selbar', '1')
2532
+ // 样式只用主题令牌(技能要求:不许硬编码颜色,明暗主题都要能看)
2533
+ //
2534
+ // ⚠️ 背景**必须不透明**(2026-10-15 主人报"这个菜单是半透明的")
2535
+ // 老面板用的是 `--dsw-alias-bg-overlay` + `backdrop-filter: blur(12px)` ✓
2536
+ // —— 那是个**磨砂玻璃**效果:底色本来就是半透明的,靠模糊才好看 ✓
2537
+ // 我第一版把 backdrop-filter 漏了 ✗ → 半透明底色直接露出来 ✗
2538
+ // 主人要**不透明** ✓ → 换成 raised surface 的令牌(它是不透明的 ✓):
2539
+ // --dsw-alias-bg-layer-1 「Primary raised surface background」
2540
+ // 并且**不要** backdrop-filter ✓(加了反而会把它变回半透明观感 ✗)
2541
+ selbar.style.cssText = 'position:fixed;z-index:100000;display:none;align-items:center;gap:1px;'
2542
+ + 'width:max-content;max-width:none;flex-wrap:nowrap;flex-shrink:0;'
2543
+ + 'padding:3px 5px;border-radius:8px;'
2544
+ + 'background:var(--dsw-alias-bg-layer-1,#ffffff);'
2545
+ + 'color:var(--dsw-alias-label-primary,#1a1a1a);'
2546
+ + 'border:1px solid var(--dsw-alias-border-l1,rgba(128,128,128,.3));'
2547
+ + 'box-shadow:0 6px 20px rgba(0,0,0,.18);'
2548
+ + 'user-select:none;-webkit-user-select:none'
2549
+ // 按下时别让浏览器把选区弄塌(否则点按钮时选区已经没了)—— 老面板同款 ✓
2550
+ selbar.addEventListener('mousedown', function (e) { e.preventDefault(); e.stopPropagation() })
2551
+ document.body.appendChild(selbar)
2552
+ return selbar
2553
+ }
2554
+
2555
+ function hideSelbar() { if (selbar) selbar.style.display = 'none' }
2556
+ // 兼容旧名(别处还在调 hideAsk)
2557
+ function hideAsk() { hideSelbar() }
2558
+
2559
+ function buildSelbar(rect) {
2560
+ var bar = ensureSelbar()
2561
+ // 先用「自然宽度」渲染(子项不收缩、文字不换行),渲染完再按实测尺寸定位。
2562
+ // 顺序很关键:先定位再渲染的话,栏会被挤窄 → 按钮收缩 → 带文字的按钮折行 ✗
2563
+ bar.style.left = '0px'; bar.style.top = '0px'
2564
+ bar.style.display = 'flex'
2565
+ bar.innerHTML = ''
2566
+ var fm = updateSelFormats(document.getSelection())
2567
+ SELBAR_ITEMS.forEach(function (item) {
2568
+ if (item.type === 'sep') {
2569
+ var sep = document.createElement('span')
2570
+ sep.style.cssText = 'width:1px;height:18px;background:rgba(128,128,128,.22);margin:0 3px;flex:0 0 auto'
2571
+ bar.appendChild(sep)
2572
+ return
2573
+ }
2574
+ var btn = document.createElement('button')
2575
+ btn.innerHTML = item.label
2576
+ btn.title = item.title || ''
2577
+ btn.style.cssText = 'padding:3px 7px;border:none;background:transparent;color:inherit;border-radius:6px;'
2578
+ + 'font-size:13px;cursor:pointer;font-family:inherit;display:flex;align-items:center;justify-content:center;'
2579
+ + 'min-width:26px;height:26px;transition:background .1s ease;flex:0 0 auto;white-space:nowrap;line-height:1'
2580
+ btn.addEventListener('mouseenter', function () { if (!btn.dataset.active) btn.style.background = 'rgba(128,128,128,.12)' })
2581
+ btn.addEventListener('mouseleave', function () { if (!btn.dataset.active) btn.style.background = 'transparent' })
2582
+ // 选中态:灰色胶囊(不遮字、深浅主题都可读)—— 老面板同款 ✓
2583
+ var on = (item.cmd === 'bold' && fm.bold) || (item.cmd === 'italic' && fm.italic)
2584
+ || (item.cmd === 'strikeThrough' && fm.strikeThrough) || (item.cmd === 'underline' && fm.underline)
2585
+ || (item.cmd === 'highlight' && fm.mark)
2586
+ || (item.cmd === 'formatBlock' && fm.heading === 'h2')
2587
+ if (on) { btn.dataset.active = '1'; btn.style.background = 'rgba(128,128,128,.18)' }
2588
+ btn.addEventListener('click', function (e) {
2589
+ e.stopPropagation()
2590
+ if (item.isAskAI) { hideSelbar(); askAi(); return }
2591
+ // 下拉那三个(标题 / 对齐 / 更多)—— 老面板的 hasDropdown 分支 ✓
2592
+ if (item.hasDropdown) {
2593
+ restoreSelection()
2594
+ if (item.isHeading) showHeadingDropdown(btn)
2595
+ else if (item.showAlign) showAlignDropdown(btn)
2596
+ else if (item.showFullMenu) showFullMenu(btn)
2597
+ return
2598
+ }
2599
+ restoreSelection() // ★ 先把选区恢复回来(点按钮时已经塌了 ✗)
2600
+ // 诊断:点完把"命令 / 选区 / 结果 / 焦点"打进日志 ✓
2601
+ // 主人报过"点了没反应"和"焦点跳到聊天框" —— 焦点那一项就在这里看 ✓
2602
+ var whoBefore = (function () {
2603
+ try {
2604
+ var a = document.activeElement
2605
+ return a ? (a.tagName + (a.getAttribute && a.getAttribute('data-ccv-md-body') ? '#正文' : '') + (a.getAttribute && a.getAttribute('data-composer-input') ? '#聊天框' : '')) : '(无)'
2606
+ } catch (_) { return '?' }
2607
+ })()
2608
+ var diag = 'cmd=' + item.cmd + ' 选区=' + JSON.stringify(String(selectionText() || '').slice(0, 20)) + ' 点前焦点=' + whoBefore
2609
+ try {
2610
+ var handled = runCanvasCmd(item.cmd)
2611
+ diag += ' 自定义=' + handled
2612
+ if (!handled) {
2613
+ var ok = document.execCommand(item.cmd, false, item.val)
2614
+ diag += ' execCommand=' + ok
2615
+ }
2616
+ var ed = editorEl()
2617
+ if (ed) { try { ed.focus() } catch (_) {} }
2618
+ var whoAfter = (function () {
2619
+ try {
2620
+ var a = document.activeElement
2621
+ return a ? (a.tagName + (a.getAttribute && a.getAttribute('data-ccv-md-body') ? '#正文' : '') + (a.getAttribute && a.getAttribute('data-composer-input') ? '#聊天框' : '')) : '(无)'
2622
+ } catch (_) { return '?' }
2623
+ })()
2624
+ diag += ' 编辑区=' + (ed ? '有' : '空') + ' 点后焦点=' + whoAfter
2625
+ } catch (err) { diag += ' 抛错=' + (err && err.message) }
2626
+ log('划词栏点击:' + diag)
2627
+ hideSelbar()
2628
+ })
2629
+ bar.appendChild(btn)
2630
+ })
2631
+ // 渲染完再测量真实尺寸并定位:优先贴选区上方,放不下就挪到下方 ✓
2632
+ var bw = bar.offsetWidth || 300
2633
+ var bh = bar.offsetHeight || 32
2634
+ var x = Math.max(8, Math.min(rect.left, window.innerWidth - bw - 8))
2635
+ var y = rect.top - bh - 10
2636
+ if (y < 8) y = Math.min(rect.bottom + 10, window.innerHeight - bh - 8)
2637
+ bar.style.left = x + 'px'
2638
+ bar.style.top = y + 'px'
2639
+ }
2640
+
2641
+ function onMouseUp(e) {
2642
+ try {
2643
+ // ★★ 点在**划词栏自己**上面 → 什么都别做(2026-10-15 修"按钮点了没反应")
2644
+ // 老面板有一道一模一样的守卫(它的 globalMouseUp 里):
2645
+ // if (bar.contains(e.target)) return
2646
+ // 我第一版漏了 ✗ —— 于是:点按钮 → 选区已经塌了 → 下面"选区为空就藏栏" ✗
2647
+ // → 栏在 click 事件之前就被藏掉 → 表现就是"按钮全都没反应" ✗✗
2648
+ if (e && e.target && e.target.closest && e.target.closest('[data-ccv-selbar]')) return
2649
+ var sel = document.getSelection()
2650
+ if (!sel || sel.rangeCount === 0) { hideSelbar(); return }
2651
+ var text = String(sel.toString() || '')
2652
+ if (!text.trim()) { hideSelbar(); return }
2653
+ if (!insideBody(sel.anchorNode) && !insideBody(sel.focusNode)) { hideSelbar(); return }
2654
+ var range = sel.getRangeAt(0)
2655
+ // ⚠️ 必须在**这一刻**存下来:点按钮之后选区可能已经塌了,那时再取就晚了
2656
+ savedRange = range.cloneRange()
2657
+ savedText = text
2658
+ buildSelbar(range.getBoundingClientRect())
2659
+ } catch (e2) { log('划词检测异常: ' + (e2 && e2.message)) }
2660
+ }
2661
+
2662
+ function ensureListeners() {
2663
+ if (listenersOn) return
2664
+ listenersOn = true
2665
+ document.addEventListener('mouseup', onMouseUp, true)
2666
+ // ── 查找条:文档级快捷键 + "编辑区没了就收掉"(2026-10-17 D1)──
2667
+ // ⚠️ 只在查找框开着时才起作用 ✓ 平时一个字都不影响 ✓
2668
+ document.addEventListener('keydown', findGlobalKey, true)
2669
+ // ⚠️ 关侧栏时没有别的信号 ✗ → 借已有的高频事件顺手看一眼 ✓
2670
+ // (selectionchange 是现成的 ✓ 而且这里只做一次极便宜的判断 ✓)
2671
+ document.addEventListener('resize', findHideIfEditorGone)
2672
+ // ── 表格工具条:光标动就同步一次(rAF 节流 ✓ selectionchange 触发非常频繁 ✗)──
2673
+ document.addEventListener('selectionchange', function () {
2674
+ // ★ 顺手把"最后已知的编辑状态"记到模块级 ✓
2675
+ // (宿主会因文件变化重建我们的组件 ✗ 重建后靠它接回焦点与光标 ✓)
2676
+ // ⚠️ 必须走**节流版** ✗✗ —— 2026-10-15 卡死事故:
2677
+ // 不节流 → 每敲一个字全文 TreeWalker → 十几万字文档卡死 DSH ✓
2678
+ noteLiveEditStateSoon()
2679
+ tableBarSyncSoon()
2680
+ })
2681
+ // 焦点进出也记一次 ✓(打字中途组件被重建时,光靠 selectionchange 可能来不及 ✓)
2682
+ document.addEventListener('focusin', function () { setTimeout(noteLiveEditState, 0) }, true)
2683
+ document.addEventListener('focusout', function () { setTimeout(noteLiveEditState, 0) }, true)
2684
+ document.addEventListener('mouseup', function (e) {
2685
+ var t2 = e.target
2686
+ if (t2 && t2.closest && (t2.closest('[data-ccv-tblbar]') || t2.closest('[data-ccv-md-body="1"]'))) { tableBarSyncSoon(); return }
2687
+ tableBarHide()
2688
+ }, true)
2689
+ document.addEventListener('mousedown', function (e) {
2690
+ var t = e.target
2691
+ // 点划词栏 / 表格工具条上的按钮不算"点别处"(否则会在 click 之前就被藏掉 ✗)
2692
+ if (t && t.closest && t.closest('[data-ccv-selbar]')) return
2693
+ if (t && t.closest && t.closest('[data-ccv-tblbar]')) return
2694
+ hideSelbar()
2695
+ }, true)
2696
+ window.addEventListener('scroll', hideAsk, true)
2697
+ // ── 关页 / 切走时立刻存(2026-10-11 加,自动保存的兜底)──────────
2698
+ // 为什么要两道:
2699
+ // · beforeunload 是"最后机会",但异步 fetch 在关页时**不保证发出去** ✗
2700
+ // · visibilitychange → hidden 更可靠(切标签页、最小化都会触发 ✓)
2701
+ // 真正的防丢靠**草稿**(localStorage 是同步的 ✓ 一定能写进去 ✓)
2702
+ // ——这两道只是"尽量把改动落盘"✓ 不能当唯一保障 ✗
2703
+ try {
2704
+ window.addEventListener('beforeunload', function () { flushAllEdits() })
2705
+ document.addEventListener('visibilitychange', function () {
2706
+ if (document.visibilityState === 'hidden') flushAllEdits()
2707
+ })
2708
+ } catch (_) {}
2709
+ log('v' + VERSION + ' 划词「问AI」已装好')
2710
+ }
2711
+
2712
+ // 把所有待保存的都立刻存一遍(关页/切走时用)
2713
+ function flushAllEdits() {
2714
+ try {
2715
+ for (var p in saveTimers) { if (saveTimers[p]) flushEdit(p) }
2716
+ for (var p2 in docStore) {
2717
+ var d = docStore[p2]
2718
+ if (d && d.dirty && !saveTimers[p2]) flushEdit(p2)
2719
+ }
2720
+ } catch (_) {}
2721
+ }
2722
+
2723
+ // ─── 点了「问AI」────────────────────────────────────────────
2724
+ // 顺序一步都不能动(跟主模块 askAiFromSelection 一致):
2725
+ // ① capture 记一笔 —— 必须赶在输入框抢焦点之前,那之后浏览器选区就塌了
2726
+ // ② 把焦点交回输入框 —— 胶囊是插进输入框的,官方那套照着"输入框当前状态"做版本比对
2727
+ // ③ 请 selref 走官方两步(打开菜单 → 选中),成功的话输入框里直接出胶囊
2728
+ // ⚠️ 这里**故意不做**"整体重写草稿"那套兜底:草稿里已有胶囊时整体重写会毁掉
2729
+ // 主人前面所有引用(2026-09-15 立下的硬规矩)。失败就明说,让他再点一次。
2730
+ function askAi() {
2731
+ var text = savedText
2732
+ hideAsk()
2733
+ if (!text || !text.trim()) return
2734
+ var toast = function (m) {
2735
+ try { if (window.__ccvKit && window.__ccvKit.toast) window.__ccvKit.toast(m, 'ccv-viewer-ask') } catch (_) {}
2736
+ }
2737
+ var rec = null
2738
+ try { if (window.__ccvSelRefCapture) rec = window.__ccvSelRefCapture() } catch (_) {}
2739
+
2740
+ // ── 建「选区编辑区」(实施步骤 1~3:建区 + 绑定 + 注入 + 拼接)──
2741
+ // 规格:canvas-docs/话布选区编辑区方案.md
2742
+ // ⚠️ **不在这里自己发请求** —— 建区的唯一实现在主模块(__ccvKit.createScratch),
2743
+ // 面板的划词栏走的是同一份。两条入口做同一件事,写两遍必然走偏 ✗
2744
+ // (2026-10-11 主人报"我每次都是打开的老话布":那时只有这边接了建区,
2745
+ // 从面板点「问AI」压根不建区 → 注入没有、拼接也不会发生 ✗)
2746
+ try {
2747
+ if (window.__ccvKit && typeof window.__ccvKit.createScratch === 'function') {
2748
+ window.__ccvKit.createScratch(rec)
2749
+ } else {
2750
+ log('建编辑区:主模块还没把 createScratch 借出来,跳过')
2751
+ }
2752
+ } catch (e) { log('建编辑区异常:' + (e && e.message)) }
2753
+
2754
+ var input = document.querySelector('div[data-composer-input="true"]')
2755
+ || document.querySelector('[contenteditable="true"][role="textbox"]')
2756
+ || document.querySelector('textarea[placeholder*="发消息"]')
2757
+ if (input) { try { input.focus() } catch (_) {} }
2758
+ var auto = null
2759
+ if (rec) { try { if (window.__ccvSelRefAuto) auto = window.__ccvSelRefAuto(rec) } catch (_) {} }
2760
+ if (auto && typeof auto.then === 'function') {
2761
+ auto.then(function (ok) {
2762
+ if (!ok) toast('插入没成功 —— 请再点一次「问AI」')
2763
+ }).catch(function () { toast('插入失败(异常),请再点一次') })
2764
+ } else {
2765
+ toast('选区引用模块还没就绪,请稍后再试')
2766
+ }
2767
+ }
2768
+
2769
+ var bodySaid = 0
2770
+
2771
+ // ─── 一个小状态:正文 ↔ 工具栏(2026-10-11 加,编辑用)────────────
2772
+ // 为什么需要它:**正文和工具栏是两个独立组件** ✗(注册在两个不同的槽),
2773
+ // 而"保存"按钮必须跟着"有没有未保存的改动"变 ✓ —— 兄弟组件没法直接传 props ✗。
2774
+ // 所以就放一个最小订阅:path → { dirty, md, saved, ver }
2775
+ // dirty 有没有未保存的改动(按钮亮不亮)
2776
+ // md 编辑区当前序列化出来的 markdown(保存时要发它)
2777
+ // saved 服务端给的原文(判断 dirty 的基准)
2778
+ // ver 画布版本号(保存时当 knownVer 带上 → host 能检出并发冲突 ✓)
2779
+ // override 撤销/重做**立刻**要显示的内容(见 Actions 里 doIt 的注释)
2780
+ // 查看器正文本来只认 props.content ✗,而那个要等文件监听才更新 ✗
2781
+ // —— 有了 override 就能当场刷新 ✓ 等 props 追上来再丢掉 ✓
2782
+ var docStore = {}
2783
+ var docSubs = []
2784
+ // 草稿"每篇只检查一次"的闸(防止重挂载时把已经作废的草稿又恢复回来 ✗)
2785
+ var draftTried = {}
2786
+ // mdToHtml 结果缓存(键 = 路径 + 正文 ✓)—— 见 Body 里的注释 ✓
2787
+ var mdCache = { key: '', html: null }
2788
+ // Body 实例计数(诊断"是不是被重新挂载了"✗ 2026-10-15)
2789
+ // 主人报「保存后还是会闪,就像把页面刷新了一下」✓
2790
+ // 日志显示**同一个 renderSeq 重设了两次** ✗ → 说明组件 ref 被重置 ✗ → 重挂载 ✓
2791
+ // 重挂载 = 元素全新 = 就像刷新了一下 ✓✓ 与主人的描述完全对得上 ✓
2792
+ var bodyInstSeq = 0
2793
+ // 拖动期间被推迟的保存(只推迟、不丢弃 ✓)
2794
+ var saveRetryTimer = null
2795
+ // "我们自己刚写过的那份"(时间 + 文本)—— 用来识别 props 回传的**回声** ✓
2796
+ // 见采纳规则③的注释:不能只比文本 ✗ 文件监听回传常带格式规整 ✓
2797
+ var ownWriteAt = 0
2798
+ var ownWriteTxt = ''
2799
+ // 输入法是否正在组合(拼音还没上屏 ✓)—— 见 Body 里 onCompositionStart 的注释 ✓
2800
+ var composing = false
2801
+ function docNotify() {
2802
+ for (var i = 0; i < docSubs.length; i++) { try { docSubs[i]() } catch (_) {} }
2803
+ }
2804
+ function docGet(path) { return path ? (docStore[normPath(path)] || null) : null } function docPatch(path, patch) {
2805
+ if (!path) return
2806
+ var k = normPath(path)
2807
+ var cur = docStore[k] || { dirty: false, md: '', saved: '', ver: 0, override: null, saving: false, id: '', buf: null, bufVer: '', renderSeq: 0 }
2808
+ for (var p in patch) { if (Object.prototype.hasOwnProperty.call(patch, p)) cur[p] = patch[p] }
2809
+ docStore[k] = cur
2810
+ docNotify()
2811
+ }
2812
+ // 订阅(用 useState 计数强制重渲染 —— 组件很小,不值得上外部状态库 ✓)
2813
+ function useDocStore(path, r) {
2814
+ var st = r.useState(0)
2815
+ var bump = st[1]
2816
+ r.useEffect(function () {
2817
+ var f = function () { bump(function (x) { return x + 1 }) }
2818
+ docSubs.push(f)
2819
+ return function () { var i = docSubs.indexOf(f); if (i >= 0) docSubs.splice(i, 1) }
2820
+ }, [path])
2821
+ return docGet(path)
2822
+ }
2823
+
2824
+ // ─── 保存 / 自动保存(2026-10-11 加,照成熟文档软件的做法)──────────
2825
+ // 成熟软件(Google Docs / Notion / VS Code / Obsidian)的共同做法:
2826
+ // 自动保存(防抖 + 失焦)+ 可见的保存状态 + 版本历史 + 本地草稿兜底。
2827
+ // 我们四样都有底气做,因为:
2828
+ // · 版本历史 —— 画布本来就有撤销栈 ✓ 而且刚加了"连续编辑合并成一步" ✓
2829
+ // (没有那一步,自动保存会把撤销栈冲爆 ✗ 按一次撤销只退一小步 ✗)
2830
+ // · 本地草稿 —— 面板早就有 ✓ 借出来共用 ✓
2831
+ // · 冲突保护 —— knownVer ✓ + "有未保存改动就不覆盖正文" ✓
2832
+ // ★★ 保存频率(2026-10-17 重新定)★★
2833
+ // 主人问:「别的软件真的有那么频繁的自动保存吗」
2834
+ // **没有** ✗ —— 成熟软件不是这么干的:
2835
+ // VS Code 默认【关闭】自动保存 ✓ 开了也只是写文件 ✓ 编辑器毫无感觉 ✗
2836
+ // Google Docs 每敲一个字发 op ✓ 频率更高 ✓ 但服务端【只回 ack】✗ 从不回灌整篇 ✓
2837
+ // Obsidian 停手 ~2 秒写文件 ✓ 编辑器不受影响 ✓
2838
+ // → 频率不是问题 ✓ **"保存之后宿主会重建我们的组件"才是** ✗
2839
+ // 文件一变 → DSH 文档预览重建组件 ✗ → 内容当 prop 回灌 → 重设 DOM → 闪 ✓
2840
+ // → 在根治那条之前,**把保存频率降下来** ✓ 闪烁次数直接从"每 1.5 秒"掉一个数量级 ✓
2841
+ //
2842
+ // 新的分工(成熟软件也是这个分工 ✓):
2843
+ // · 草稿(localStorage ✓ 不碰文件 ✓ 不影响界面 ✓)→ 勤一点 ✓ 防丢 ✓
2844
+ // · 写盘(会触发宿主重建 ✗)→ **懒一点** ✓ 只在"真的要离开这段输入"时写 ✓
2845
+ // · 失焦 / Ctrl+S / 切换文档 → **立刻写** ✓(这些时机本来就是"要走了"✓)
2846
+ // ⚠️ 2026-10-29 主人:「现在还有自动保存吗,自动保存又打断了我的操作」
2847
+ // 机制说清楚:**写盘会改磁盘文件** → 宿主监听到 → 重建这个组件 ✗
2848
+ // → 编辑区 innerHTML 被重设 → 光标/选区被抢 → 这就是"打断" ✓
2849
+ // 这个重建在客户端**没法规避** ✗ 所以只能少写盘 ✓
2850
+ // 策略(成熟做法:草稿勤快、落盘保守):
2851
+ // · 草稿每 800ms 写 localStorage ✓ 无副作用、不打断 ✓ 崩了能恢复 ✓
2852
+ // · 真正落盘:**停手 20 秒**才写一次 ✓(原来 5 秒 ✗ 太勤)
2853
+ // · 离开编辑区(失焦)/ 切后台 / 关页 → 立刻落盘 ✓
2854
+ // · Ctrl+S / 点保存按钮 → 立刻落盘 ✓
2855
+ var AUTOSAVE_MS = 20000 // 停手 20 秒才写盘(2026-10-29 由 5 秒放宽)
2856
+ var DRAFT_MS = 800 // 草稿照旧勤快 ✓ 它只写 localStorage ✓ 没有任何副作用 ✓
2857
+ // ★ "你最后敲键盘是什么时候"(2026-10-17 加)★
2858
+ // 写盘前要靠它确认"你现在没在打字"✗ —— 见 saveDoc 里那条跳过 ✓
2859
+ // 为什么必须有:主人点出「卡着第五秒打字」那个窗口 ✗
2860
+ // 计时器到点、请求在路上,你恰好又开始打字 → 闪就落在你手上 ✓
2861
+ var lastInputAt = 0
2862
+ var saveTimers = {}
2863
+ var draftTimers = {}
2864
+ function toastOn(msg) {
2865
+ try { if (window.__ccvKit && window.__ccvKit.toast) window.__ccvKit.toast(msg, 'ccv-viewer-save') } catch (_) {}
2866
+ }
2867
+
2868
+ // 真正写盘。opts.manual = 人点的(要提示 ✓)/ 自动保存(安静 ✓ 只在失败时说话 ✓)
2869
+ function saveDoc(path, opts) {
2870
+ opts = opts || {}
2871
+ // ★★★ 最要紧的一条:**你正在打字就不要写盘** ★★★(2026-10-17 主人点出来的)
2872
+ // 主人原话:「只在失焦保存,我要是卡着第五秒打字,还是有问题」
2873
+ // 他说得对 ✗ —— 任何**定时**写盘都可能正好落在你打字中间 ✓
2874
+ // 定时器到点 → 发保存 → 文件变 → 宿主重建组件 ✗ → 闪 ✓ → 打断你 ✓
2875
+ // 而"重置计时器"挡不住这个:计时器**已经到点、请求正在路上**那一瞬间,
2876
+ // 你恰好又开始打字了 ✗
2877
+ // 做法:**发之前再确认一次** ✓
2878
+ // · 最近 1.2 秒内有输入 → **跳过这次** ✗(不是重排 ✗✗ 不会循环 ✓)
2879
+ // · 跳过是安全的:你继续打字 → 计时器会重新排 ✓
2880
+ // 你停手 → 下一次到点就会存 ✓
2881
+ // 你走开(失焦)→ 立刻存 ✓
2882
+ // 而且**草稿 800ms 就写好了** ✓ 一个字都不会丢 ✓
2883
+ // ⚠️ 手动(Ctrl+S / 按钮)不受这条限制 ✓ —— 那是你明确要求的 ✓
2884
+ // ⚠️ 2026-10-29:窗口从 1.2 秒放宽到 **3 秒** ✓ 并把"人还停在编辑区里"也算进来 ✓
2885
+ // 理由:主人报「自动保存又打断了我的操作」——
2886
+ // 写盘 → 宿主重建组件 → 光标被抢 ✓
2887
+ // 只要人还在这个文档里、且最近 3 秒动过,就不要写盘 ✓
2888
+ // (草稿 800ms 已经在 localStorage 里 ✓ 一个字都不会丢 ✓)
2889
+ var ae = null
2890
+ try { ae = document.activeElement } catch (_) {}
2891
+ var stillInEditor = false
2892
+ try { stillInEditor = !!(ae && ae.closest && ae.closest('[data-ccv-md-body="1"]')) } catch (_) {}
2893
+ if (!opts.manual && ((lastInputAt && (Date.now() - lastInputAt) < 3000) || (stillInEditor && (Date.now() - lastInputAt) < 8000))) {
2894
+ log('刚还在打字(' + (Date.now() - lastInputAt) + 'ms 前)→ 这次自动保存跳过 ✓'
2895
+ + '(继续打字会重排 ✓ 停手会存 ✓ 走开会立刻存 ✓ 草稿已保住 ✓)')
2896
+ docPatch(path, { saving: false })
2897
+ return
2898
+ }
2899
+ // ★★ 正在拖表格列宽/行高时**不要保存**(2026-10-15)★★
2900
+ // 主人报"自动保存会卡我拉表格" ✓ 链条是:
2901
+ // 保存 → docPatch → docNotify → Body 重渲染 ✗(+ 网络请求 ✓)
2902
+ // 你正拉着表格 → 正好撞上 = 一卡 ✓
2903
+ // 做法:**推迟到拖完** ✓(300ms 后再试一次 ✓ 拖动中就一直往后推 ✓)
2904
+ // ⚠️ 只推迟、**不丢弃** ✗ —— 拖完那一帧照样会 fireInput → 自动保存排上 ✓
2905
+ if (typeof tableDragging !== 'undefined' && tableDragging) {
2906
+ log('正在拖表格 → 这次保存推迟(' + (opts.manual ? '手动' : '自动') + ')')
2907
+ if (saveRetryTimer) clearTimeout(saveRetryTimer)
2908
+ saveRetryTimer = setTimeout(function () { saveRetryTimer = null; saveDoc(path, opts) }, 300)
2909
+ return
2910
+ }
2911
+ // ★★ 输入法正在组合时也不要保存(2026-10-15)★★
2912
+ // 主人报「打一长串拼音还没选字,自动保存就打断我」——
2913
+ // 保存回来会 docPatch → 重渲染 ✗ 而重设 innerHTML 会**打断输入法组合** ✗✗
2914
+ // (组合期间我们本来就不标脏、不排保存 ✓ 这道是兜住"组合开始前就排上的那一次" ✓)
2915
+ //
2916
+ // ⚠️⚠️ 2026-10-17 事故:这里原来会**每 400 毫秒自我重排一次** ✗✗
2917
+ // 主人现场:打一行字 → 停手(自动保存排上)→ 再打第二行(还在拼音里)→
2918
+ // 日志刷出成百上千行「输入法组合中 → 这次保存推迟」✗
2919
+ // (13:01:43 → 13:01:53 每 400ms 一条 ✓)
2920
+ // → 日志是**每行一次 POST** ✗ → 浏览器与服务端被淹 ✓ → **页面崩掉刷新** ✗✗
2921
+ // → 拼音里还没上屏的内容**全丢** ✓✓
2922
+ // 修:**只推迟、不自我重排** ✗
2923
+ // 组合结束时 onCompositionEnd 会调 handleEditInput() ✓ → 它会重新排保存 ✓
2924
+ // (所以不会漏存 ✓ 只是把"重排"这件事交给组合结束 ✓)
2925
+ if (composing) {
2926
+ log('输入法组合中 → 这次保存跳过(组合结束会自己再排一次 ✓ 不再自我重排 ✗)')
2927
+ docPatch(path, { saving: false })
2928
+ return
2929
+ }
2930
+ var d = docGet(path)
2931
+ var id = (d && d.id) || ''
2932
+ if (!id) { if (opts.manual) toastOn('这份文件不是话布,存不了'); return }
2933
+ if (!d || !d.dirty) { if (opts.manual) toastOn('没有改动要保存'); return }
2934
+ if (d.saving) return
2935
+ docPatch(path, { saving: true })
2936
+ var mdToSave = d.md
2937
+ // ★★★ 绝不允许把文档写成空的(2026-10-15,主人现场「文档变空白了」)★★★
2938
+ // 链路:客户端某个时刻 md 变成 '' ✗ → 自动保存把它写进服务端 ✗✗
2939
+ // → 历史里留下一条 after='' 的条目 ✗ → 之后点【重做】把 '' 写回 → 空白 ✓
2940
+ // ⚠️ 判据必须用**编辑区的真实内容**(DOM 才是真相 ✓)而不是 d.md ✗
2941
+ // —— 否则"主人真的全选删掉"这种**合法**操作也会被拦 ✗
2942
+ // 规则:要保存的是空的、但**编辑区里明明还有字** → 状态机错乱 → 拒绝保存 ✓
2943
+ // (编辑区也是空的 → 那是主人真的要清空 → 照常保存 ✓)
2944
+ var domNow = ''
2945
+ try { domNow = liveEditor ? String(window.__ccvKit.htmlToMd(liveEditor) || '') : '' } catch (_) {}
2946
+ if (!mdToSave && domNow) {
2947
+ log('拒绝保存空内容(要存的是空的,但编辑区里还有 ' + domNow.length + ' 字 ✗ 状态机错乱)')
2948
+ docPatch(path, { saving: false })
2949
+ return
2950
+ }
2951
+ try {
2952
+ fetch('/api/canvas/write', {
2953
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
2954
+ body: JSON.stringify({ id: id, content: mdToSave, knownVer: d.ver }),
2955
+ }).then(function (x) { return x.json() })
2956
+ .then(function (res) {
2957
+ log((opts.manual ? '保存(手动)' : '自动保存') + ' → ' + JSON.stringify(res))
2958
+ if (!res || !res.ok) {
2959
+ docPatch(path, { saving: false })
2960
+ // ── 版本冲突:单独认出来,说清楚 ✓(2026-10-15)──────────────
2961
+ // 服务端的冲突响应是 {"ok":false,"conflict":true,"currentVersion":N,
2962
+ // "currentContent":"…"} —— **没有 error 字段** ✗
2963
+ // 而原来那句 `res.error || '服务端没响应'` 会把真实原因**盖住** ✗✗
2964
+ // (主人 2026-10-15 报"所有操作都弹保存失败:服务端无响应",
2965
+ // 真相是版本冲突 —— 日志里 res 明明白白写着 conflict:true ✓)
2966
+ if (res && res.conflict) {
2967
+ var cur = (typeof res.currentVersion === 'number') ? res.currentVersion : '?'
2968
+ log('保存冲突:本地 v' + d.ver + ' / 服务端 v' + cur
2969
+ + '(文档在别处被改过 ✓ 服务端把最新内容也给了 ✓ 不许静默覆盖 ✗)')
2970
+ // ★★ 关键:把版本对齐到服务端,否则**永远**冲突(死锁 ✗✗)★★
2971
+ // 内容与 dirty 都不动 ✓(用户的改动一点不丢 ✓)
2972
+ // 对齐之后下一次自动保存就能写进去 ✓
2973
+ // —— 但**不能默默覆盖** ✗ 所以提示里把"会写回/会覆盖"说清楚 ✓
2974
+ docPatch(path, {
2975
+ ver: (typeof res.currentVersion === 'number') ? res.currentVersion : d.ver,
2976
+ conflict: { ver: cur, content: res.currentContent },
2977
+ })
2978
+ toastOn('文档已被别处改动(本地 v' + d.ver + ' / 服务端 v' + cur
2979
+ + ')—— 已对齐版本,你的改动会写回(会覆盖服务端那版)')
2980
+ return
2981
+ }
2982
+ // 其它拒绝:把服务端原样返回的东西打出来 ✓(别用兜底话盖住真实原因 ✗)
2983
+ var why = (res && (res.error || res.message || res.reason)) || ''
2984
+ if (!why && res) { try { why = JSON.stringify(res).slice(0, 200) } catch (_) { why = '' } }
2985
+ log('保存被拒绝:' + (why || '(服务端没给原因)') + ' knownVer=' + d.ver)
2986
+ toastOn('保存失败:' + (why || '服务端没给原因(见日志)'))
2987
+ return
2988
+ }
2989
+ docPatch(path, {
2990
+ saving: false, dirty: false, saved: mdToSave,
2991
+ ver: (typeof res.version === 'number') ? res.version : d.ver,
2992
+ // ★★ 必须把 canUndo/canRedo 也更新掉(2026-10-15,主人问"保存是不是导致撤销历史为空")★★
2993
+ // 服务端每次都回这两个字段 ✓ 而这里原来只更新 saving/dirty/saved/ver ✗
2994
+ // → 保存之后 canUndo 还停在旧值(多半是 false ✗)
2995
+ // → Ctrl+Z 判定"画布没得撤" → 交给浏览器原生 ✓
2996
+ // → 而保存回声会让 React 重设 innerHTML ✗ → **原生撤销栈被清空** ✗✗
2997
+ // → 结果:按 Ctrl+Z 什么都不发生 = 主人说的"撤销历史为空" ✓
2998
+ canUndo: (typeof res.canUndo === 'boolean') ? res.canUndo : true,
2999
+ canRedo: !!res.canRedo,
3000
+ // 记下"这份是我们自己刚写进去的" ✓ —— 采纳规则③靠它认出 props 的回声 ✓
3001
+ justSaved: mdToSave,
3002
+ })
3003
+ // ★ 同时记进模块级(时间 + 文本)✓
3004
+ // 规则③不能只比文本 ✗ —— 文件监听回传常带格式规整(尾换行等 ✓)
3005
+ // 文本对不上就会被当外部改动 → renderSeq +1 → 整篇重设 → 闪一下 + 丢焦点 ✗✗
3006
+ // (2026-10-15 主人现场「保存后还是会闪,就像刷新了一下」✓)
3007
+ ownWriteAt = Date.now()
3008
+ ownWriteTxt = mdToSave
3009
+ // 草稿功成身退(内容已经落盘 ✓)
3010
+ try { if (window.__ccvKit && window.__ccvKit.draftDropIf) window.__ccvKit.draftDropIf(id, mdToSave) } catch (_) {}
3011
+ if (opts.manual) toastOn('已保存 ✓')
3012
+ })
3013
+ .catch(function (e) {
3014
+ docPatch(path, { saving: false })
3015
+ toastOn('保存失败:' + (e && e.message))
3016
+ })
3017
+ } catch (e) {
3018
+ docPatch(path, { saving: false })
3019
+ toastOn('保存失败:' + (e && e.message))
3020
+ }
3021
+ }
3022
+
3023
+ // 打字之后:先存草稿(快 ✓ 防丢),再排一次自动保存(慢一点 ✓ 写盘)
3024
+ function afterEdit(path, md, id, ver) {
3025
+ if (draftTimers[path]) clearTimeout(draftTimers[path])
3026
+ draftTimers[path] = setTimeout(function () {
3027
+ draftTimers[path] = null
3028
+ try { if (window.__ccvKit && window.__ccvKit.draftSet) window.__ccvKit.draftSet(id, md, ver) } catch (_) {}
3029
+ }, DRAFT_MS)
3030
+ if (saveTimers[path]) clearTimeout(saveTimers[path])
3031
+ saveTimers[path] = setTimeout(function () {
3032
+ saveTimers[path] = null
3033
+ saveDoc(path, { manual: false })
3034
+ }, AUTOSAVE_MS)
3035
+ }
3036
+ // 失焦 / 关页时立刻存(成熟软件都这么做 ✓ 别让最后一段输入卡在防抖里 ✗)
3037
+ function flushEdit(path) {
3038
+ if (saveTimers[path]) { clearTimeout(saveTimers[path]); saveTimers[path] = null }
3039
+ saveDoc(path, { manual: false })
3040
+ }
3041
+
3042
+ // ★★ 离开页面 / 切到后台 → 立刻存一次(2026-10-17 加)★★
3043
+ // 为什么必须有:自动保存改成 5 秒之后 ✓ 万一你正好在这 5 秒里切走/关页 ✗
3044
+ // 成熟软件(VS Code / Obsidian / 飞书)都有这一手 ✓
3045
+ // 两条通道,各管一段:
3046
+ // · visibilitychange(切到后台 ✓)→ **写盘 + 草稿** ✓(fetch 还来得及发出去 ✓)
3047
+ // · beforeunload(真关页 ✗)→ **只写草稿** ✓
3048
+ // (localStorage 是同步的 ✓ 可靠 ✓;fetch 在关页时可能被丢掉 ✗ 不能指望 ✓)
3049
+ function flushAllDrafts() {
3050
+ try {
3051
+ var n = 0
3052
+ for (var k in docStore) {
3053
+ var d = docStore[k]
3054
+ if (!d || !d.id || !d.dirty) continue
3055
+ try {
3056
+ if (window.__ccvKit && window.__ccvKit.draftSet) {
3057
+ window.__ccvKit.draftSet(d.id, d.md || '', d.ver || 0)
3058
+ n++
3059
+ }
3060
+ } catch (_) {}
3061
+ }
3062
+ if (n) log('离开前把 ' + n + ' 篇未保存的改动写进草稿 ✓(localStorage ✓ 同步可靠 ✓)')
3063
+ } catch (_) {}
3064
+ }
3065
+ function flushAllSaves() {
3066
+ try {
3067
+ for (var k in docStore) {
3068
+ var d = docStore[k]
3069
+ if (!d || !d.id || !d.dirty) continue
3070
+ flushEdit(k) // 立刻写盘 ✓(走 saveDoc ✓ 有冲突保护 ✓)
3071
+ }
3072
+ } catch (_) {}
3073
+ }
3074
+ try {
3075
+ document.addEventListener('visibilitychange', function () {
3076
+ if (document.hidden) { flushAllDrafts(); flushAllSaves() }
3077
+ })
3078
+ window.addEventListener('beforeunload', function () { flushAllDrafts() })
3079
+ } catch (_) {}
3080
+
3081
+ // ─── 编辑区快捷键(2026-10-15 加)────────────────────────────
3082
+ // 老画布面板有一套(随面板删了 ✗),查看器这版原来**一个都没有** ✗ ——
3083
+ // 而写 markdown 的人每天都在按 Ctrl+B,没有就是"手感不对" ✗
3084
+ //
3085
+ // 用 document.execCommand:它虽然被标为废弃 ✗,但**是 contentEditable 上唯一
3086
+ // 能保留选区**的做法 ✓(手动改 DOM 会把光标弄丢 ✗)。而且它改完会**自动触发 input 事件** ✓
3087
+ // → 上面那条 onInput 的脏标记/自动保存/草稿全都自动接上 ✓ 不用另接一套 ✓
3088
+ function onEditKeyDown(e, path) {
3089
+ try {
3090
+ if (!e) return
3091
+ var mod = !!(e.ctrlKey || e.metaKey)
3092
+ var k = String(e.key || '')
3093
+
3094
+ // ── Ctrl+S:拦掉浏览器的"保存网页"对话框,改成存话布 ✓✓
3095
+ // 这个最烦:写文档的人习惯性按 Ctrl+S,弹出来的却是"另存为" ✗
3096
+ if (mod && (k === 's' || k === 'S')) {
3097
+ e.preventDefault()
3098
+ if (saveTimers[path]) { clearTimeout(saveTimers[path]); saveTimers[path] = null }
3099
+ var d = docGet(path)
3100
+ if (d && d.dirty) { saveDoc(path, { manual: true }) } else { toastOn('没有改动要保存') }
3101
+ return
3102
+ }
3103
+
3104
+ if (!mod) {
3105
+ // ── Tab / Shift+Tab ──
3106
+ // ① 在**表格单元格**里 → 跳到下一个/上一个单元格 ✓(Word / 飞书 / Google Docs 都这样)
3107
+ // 到最后一个单元格再按 Tab → **自动加一行** ✓(Word 的行为)
3108
+ // ② 不在表格里 → 插两个空格当缩进(不用 execCommand('indent'),那个会插 blockquote ✗)
3109
+ if (k === 'Tab') {
3110
+ e.preventDefault()
3111
+ if (cellOf()) { moveCell(e.shiftKey); return }
3112
+ try { document.execCommand('insertText', false, ' ') } catch (_) {}
3113
+ }
3114
+ return
3115
+ }
3116
+
3117
+ var low = k.toLowerCase()
3118
+ // ── Ctrl+F:打开查找 / 替换(D1 · 2026-10-17)──
3119
+ // 浏览器自己的 Ctrl+F 是"找网页文字"✗ 在我们的编辑器里没用 ✓
3120
+ // (它找不到 contentEditable 里你想要的"下一处"手感 ✓ 也没法替换 ✓)
3121
+ if (low === 'f') {
3122
+ e.preventDefault()
3123
+ findShow(path)
3124
+ return
3125
+ }
3126
+ // ── Esc:关掉查找条(顺手也关掉别的浮层 ✓)──
3127
+ if (k === 'Escape' || k === 'Esc') {
3128
+ try {
3129
+ if (findBar && findBar.style.display !== 'none') { e.preventDefault(); findHide(); return }
3130
+ } catch (_) {}
3131
+ }
3132
+ // ── Ctrl+Z / Ctrl+Shift+Z / Ctrl+Y:老面板的做法(阶段 5 搬过来的 ✓)──
3133
+ // 本地有没保存的编辑 → 交给浏览器原生撤销 ✓
3134
+ // 本地是干净的(比如刚被 AI 改过)→ 走**画布撤销** ✓ 这样 Ctrl+Z 能退掉 AI 的改动 ✓
3135
+ //
3136
+ // ⚠️ 2026-10-15 修一个真 bug(主人问"只有撤销的快捷键没有重做的吗"):
3137
+ // 原条件只看"本地是否干净" ✗ 于是:
3138
+ // 格式化一下(本地变脏)→ Ctrl+Z(原生撤销 ✓ 回到基准 ✓)
3139
+ // → 再按 Ctrl+Y → 此时本地"看起来干净" → **被我们接管** ✗
3140
+ // → 而画布的重做栈是空的(原生那次撤销没进画布历史 ✗)→ noop ✗
3141
+ // → 表现就是「重做没反应」✗✗(浏览器的原生重做被我们挡住了 ✓)
3142
+ // 修法:**画布没有可撤销/可重做的,就别抢** ✓ 交给浏览器原生 ✓
3143
+ if (low === 'z' || low === 'y') {
3144
+ var isRedo = (low === 'y') || (low === 'z' && e.shiftKey)
3145
+ var d0 = docGet(path)
3146
+ // ★ 判据(2026-10-29 主人:「怎么没有撤回了」)★
3147
+ // 原来有一条 `if (d0 && d0.dirty) return` ✗ —— "有未保存改动就交给浏览器原生"
3148
+ // 但原生撤销栈在**保存往返后经常是空的** ✗
3149
+ // 而自动保存刚放宽到 20 秒 → dirty 长时间为真 ✗
3150
+ // → 每次 Ctrl+Z 都交给原生 → 原生没得撤 → **就是"按了没反应"** ✓✓
3151
+ // 现在改成"服务端优先":
3152
+ // ① 未保存的改动**只写草稿**(不落盘 ✗ —— 落盘会清空服务端的重做栈 ✓✓)
3153
+ // 这就是"撤销后不能重做"的真正原因:我上一版在这里先 flushEdit(保存)
3154
+ // 而宿主的写盘逻辑会把 redoStack 清空 ✓ → 重做永远 noop ✓
3155
+ // ② 再让服务端撤销/重做(它有真实历史栈,含 AI 改动)
3156
+ // ③ 服务端回 noop(真没得撤)→ 再回退到浏览器原生 ✓
3157
+ if (d0 && d0.dirty) {
3158
+ try {
3159
+ if (window.__ccvKit && window.__ccvKit.draftSet) {
3160
+ window.__ccvKit.draftSet(d0.id, String(d0.buf || d0.md || ''), d0.ver)
3161
+ }
3162
+ } catch (_) {}
3163
+ }
3164
+ e.preventDefault()
3165
+ var id0 = (d0 && d0.id) || ''
3166
+ if (!id0) { try { document.execCommand(isRedo ? 'redo' : 'undo') } catch (_) {} ; return }
3167
+ try {
3168
+ fetch('/api/canvas/' + (isRedo ? 'redo' : 'undo'), {
3169
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
3170
+ body: JSON.stringify({ id: id0 }),
3171
+ }).then(function (x) { return x.json() }).then(function (dd) {
3172
+ log('快捷键 ' + (isRedo ? '重做' : '撤销') + ' → ' + JSON.stringify(dd).slice(0, 160))
3173
+ if (dd && dd.ok) {
3174
+ // 服务端每次都回 canUndo/canRedo ✓ 拿它更新本地状态(否则快捷键会一直判错 ✗)
3175
+ var patch0 = { canUndo: !!dd.canUndo, canRedo: !!dd.canRedo }
3176
+ // ★★★ 不许把正文改成空 ✗✗(2026-10-15 主人现场:点重做 → 文档空白)★★★
3177
+ // 宿主历史里如果有一条 after='' 的坏条目 ✓ 重做就会把 '' 写回 ✗
3178
+ // 判据:返回的是空串、而我们手上明明有内容 → **拒绝采用** ✓
3179
+ // (这是最后一道闸 ✓ 宁可"没反应",也绝不清空文档 ✗)
3180
+ var newTxt = (typeof dd.content === 'string') ? dd.content : null
3181
+ var haveTxt = String((d0 && (d0.buf || d0.md || d0.saved)) || '').length
3182
+ if (newTxt !== null && newTxt.length === 0 && haveTxt > 0) {
3183
+ log('撤销/重做返回了空内容 → 拒绝采用(手上还有 ' + haveTxt + ' 字 ✗ 避免清空文档)')
3184
+ newTxt = null
3185
+ toastOn('这次' + (isRedo ? '重做' : '撤销') + '会清空文档,已拒绝 —— 内容还在 ✓')
3186
+ }
3187
+ // noop = 没得撤/没得重做 ✓ 正文本来就对 ✓ 别去动它 ✗
3188
+ // ⚠️ 2026-10-29:服务端说 noop 时**回退到浏览器原生** ✓
3189
+ // 以前直接 toast 就完了 ✗ 而原生栈里可能还有条目 ✓
3190
+ // (这也是"服务端优先 + 原生兜底"的另一半 ✓)
3191
+ if (dd.noop) {
3192
+ newTxt = null
3193
+ try { document.execCommand(isRedo ? 'redo' : 'undo') } catch (_) {}
3194
+ }
3195
+ if (newTxt !== null) {
3196
+ patch0.override = newTxt
3197
+ patch0.saved = newTxt
3198
+ patch0.md = newTxt
3199
+ patch0.dirty = false
3200
+ // ★ 缓冲也要跟着走 ✓ 而且**必须 +1 renderSeq** ✓
3201
+ // —— 撤销/重做是"内容来自外部"的入口之一 ✓ 不 +1 的话正文不会更新 ✗
3202
+ patch0.buf = newTxt
3203
+ patch0.renderSeq = ((d0 && d0.renderSeq) || 0) + 1
3204
+ }
3205
+ docPatch(path, patch0)
3206
+ if (!dd.ok) toastOn('操作失败(见日志)')
3207
+ } else { toastOn('操作失败(见日志)') }
3208
+ }).catch(function () {})
3209
+ } catch (_) {}
3210
+ return
3211
+ }
3212
+ // ── Ctrl+B 加粗 / Ctrl+I 斜体 ──
3213
+ if (low === 'b') { e.preventDefault(); try { document.execCommand('bold') } catch (_) {} return }
3214
+ if (low === 'i') { e.preventDefault(); try { document.execCommand('italic') } catch (_) {} return }
3215
+ // ── Ctrl+` 行内代码(execCommand 没有 code 这个命令 → 自己包一层 ✓)──
3216
+ if (k === '`' || k === '~') {
3217
+ e.preventDefault()
3218
+ try {
3219
+ var sel = String(window.getSelection ? window.getSelection().toString() : '')
3220
+ var esc = sel.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
3221
+ document.execCommand('insertHTML', false, '<code>' + esc + '</code>')
3222
+ } catch (_) {}
3223
+ return
3224
+ }
3225
+ // ── Ctrl+K 链接 ──
3226
+ if (low === 'k') {
3227
+ e.preventDefault()
3228
+ try {
3229
+ var sel2 = String(window.getSelection ? window.getSelection().toString() : '')
3230
+ var url = window.prompt('链接地址(留空则只保留选中的文字)', 'https://')
3231
+ if (url === null) return
3232
+ var text = sel2 || url
3233
+ var href = (url && url !== 'https://') ? url : text
3234
+ document.execCommand('insertHTML', false,
3235
+ '<a href="' + href.replace(/"/g, '&quot;') + '">' + text.replace(/</g, '&lt;') + '</a>')
3236
+ } catch (_) {}
3237
+ return
3238
+ }
3239
+ } catch (err) { log('快捷键出错: ' + (err && err.message)) }
3240
+ }
3241
+
3242
+ // ─── 正文组件 ──────────────────────────────────────────────
3243
+ // ⚠️ hooks 必须**无条件、最前面**调用 —— 所以外层只管取 React,内层才是真组件。
3244
+ function Body(props) {
3245
+ var r = ensureR()
3246
+ if (!r) return null
3247
+ return BodyInner(props, r)
3248
+ }
3249
+
3250
+ function BodyInner(props, r) {
3251
+ var h = r.createElement
3252
+
3253
+ // ── hooks(无条件)──
3254
+ // ★ 卸载时:编辑区真的没了就把查找条收掉 ✓(2026-10-17 D1)★
3255
+ // 主人报:「搜索窗口在我关了侧栏后还存在」✗
3256
+ // 为什么不能"一卸载就收"✗:宿主**每次保存都会重建这个组件** ✓
3257
+ // 那样你正在搜索时查找框会突然消失 ✓
3258
+ // → 交给 findHideIfEditorGone:**等一拍**再看编辑区还在不在文档里 ✓
3259
+ // 重挂载 → 新编辑区还在 ✓ 不收 ✓
3260
+ // 真关闭(侧栏关了)→ 编辑区没了 ✓ 收 ✓
3261
+ r.useEffect(function () {
3262
+ return function () { findHideIfEditorGone() }
3263
+ }, [])
3264
+ // ★ 目录:订阅工具栏的开关 + 扫标题 + 滚动高亮(2026-10-24)★
3265
+ // ⚠️ 依赖数组必须是 [] ✗ 否则每次内容变化都重新绑一次 scroll ✓ 又卡又抖 ✓
3266
+ r.useEffect(function () {
3267
+ var refresh = function () {
3268
+ try {
3269
+ var out = buildOutline(edRef.current)
3270
+ setOutline(out)
3271
+ setActIdx(tocActiveIndex(edRef.current, out))
3272
+ } catch (_) {}
3273
+ }
3274
+ tocRefreshFn = refresh
3275
+ var onNav = function () {
3276
+ setTocOn(navOpen)
3277
+ if (navOpen) refresh()
3278
+ }
3279
+ navSubs.push(onNav)
3280
+ if (navOpen) refresh() // 重建后(宿主保存会重建)恢复目录内容 ✓
3281
+ // 滚动高亮:rAF 节流 + 只读位置 ✓
3282
+ var ed0 = null
3283
+ var raf = 0
3284
+ var onScroll = function () {
3285
+ try {
3286
+ if (raf) return
3287
+ raf = (window.requestAnimationFrame || function (f) { return setTimeout(f, 16) })(function () {
3288
+ raf = 0
3289
+ try {
3290
+ if (!navOpen) return
3291
+ var ix = tocActiveIndex(edRef.current, outlineRef.current)
3292
+ setActIdx(function (prev) { return prev === ix ? prev : ix })
3293
+ } catch (_) {}
3294
+ })
3295
+ } catch (_) {}
3296
+ }
3297
+ // 监听**正文自己**(它是滚动容器 ✓)
3298
+ var t = setTimeout(function () {
3299
+ try {
3300
+ ed0 = edRef.current
3301
+ if (ed0) ed0.addEventListener('scroll', onScroll, { passive: true })
3302
+ } catch (_) {}
3303
+ }, 0)
3304
+ return function () {
3305
+ try { tocRefreshFn = null } catch (_) {}
3306
+ var i = navSubs.indexOf(onNav)
3307
+ if (i >= 0) navSubs.splice(i, 1)
3308
+ clearTimeout(t)
3309
+ try { if (ed0) ed0.removeEventListener('scroll', onScroll) } catch (_) {}
3310
+ }
3311
+ }, [])
3312
+ r.useEffect(function () {
3313
+ outlineRef.current = outline
3314
+ }, [outline])
3315
+ // ★ 量容器尺寸 → 决定停靠还是浮层(2026-10-24)★
3316
+ // ResizeObserver 盯着外层(正文的父级 = 我们那个 shell ✓)
3317
+ // ⚠️ 只在**模式真的变了**才 setState ✗(否则每次改尺寸都重渲染 → 卡)
3318
+ r.useEffect(function () {
3319
+ var ro = null
3320
+ var last = null
3321
+ var measure = function () {
3322
+ try {
3323
+ var ed = edRef.current
3324
+ var shell = ed && ed.parentElement
3325
+ if (!shell) return
3326
+ var w = shell.clientWidth || 0
3327
+ var h = shell.clientHeight || 0
3328
+ if (w < 60 || h < 60) return
3329
+ var d = (w >= h) // 宽 ≥ 高 → 停靠 ✓
3330
+ if (last === d) return
3331
+ if (last !== null) log('目录:' + (d ? '停靠' : '浮层') + '(容器 ' + w + '×' + h + ')')
3332
+ last = d
3333
+ setDocked(d)
3334
+ // ★ 诊断:把【实际生效】的内距打出来(2026-10-25 主人:「这边距哪里改好了」)★
3335
+ // 只打一次,能一眼看出 CSS 到底有没有生效 ✓ 不用再猜 ✗
3336
+ try {
3337
+ var cs = window.getComputedStyle(ed)
3338
+ log('正文内距实际值:left=' + cs.paddingLeft + ' right=' + cs.paddingRight
3339
+ + '|容器 ' + w + '×' + h + '|模式=' + (d ? '停靠' : '浮层'))
3340
+ } catch (_) {}
3341
+ } catch (_) {}
3342
+ }
3343
+ var t = setTimeout(measure, 0)
3344
+ try {
3345
+ if (window.ResizeObserver) {
3346
+ ro = new window.ResizeObserver(function () { measure() })
3347
+ var ed0 = edRef.current
3348
+ if (ed0 && ed0.parentElement) ro.observe(ed0.parentElement)
3349
+ }
3350
+ } catch (_) {}
3351
+ return function () {
3352
+ clearTimeout(t)
3353
+ try { if (ro) ro.disconnect() } catch (_) {}
3354
+ }
3355
+ }, [])
3356
+ var inputState = null
3357
+ try { inputState = props.useInput ? props.useInput(function (s) { return s }) : null } catch (_) {}
3358
+ var fileInfo = null
3359
+ try { fileInfo = props.useResource ? props.useResource(props.resourceAddress) : null } catch (_) {}
3360
+ // ─── 文档路径:三级来源(2026-10-15 修「这份文件不是话布,存不了」)──────
3361
+ // 主人报:**在独立标签页里**打开话布 → 弹「这份文件不是话布,存不了」✗
3362
+ // 原因:Body 原来只认 useResource(...).value.absolutePath ✗
3363
+ // 而**标签页里这个 hook 给不出 absolutePath** ✗
3364
+ // (lastAbsPath 那条路也不行 ✗ —— 它由 Actions 组件留下,
3365
+ // 而 Actions 注册在 sidebar.right.tab.document.actions,
3366
+ // **标签页里根本没有那个 slot** ✗)
3367
+ // → absPath = '' → docPatch('') 直接 return → store 里没有 id ✗ → 存不了 ✓
3368
+ // 修:三级兜底 ✓ 并把**实际用的是哪条**打进日志 ✓(下次一眼定位 ✓)
3369
+ var absPath = ''
3370
+ var absFrom = ''
3371
+ try {
3372
+ var fv = fileInfo && fileInfo.value
3373
+ if (fv && fv.absolutePath) { absPath = fv.absolutePath; absFrom = 'useResource.absolutePath' }
3374
+ else if (fv && typeof fv === 'string') { absPath = fv; absFrom = 'useResource(字符串)' }
3375
+ else if (fv && fv.path) { absPath = fv.path; absFrom = 'useResource.path' }
3376
+ } catch (_) {}
3377
+ if (!absPath) {
3378
+ var ra = props && props.resourceAddress
3379
+ if (typeof ra === 'string' && ra) {
3380
+ var cand = ra.replace(/^file:\/\/\/?/, '')
3381
+ try { cand = decodeURIComponent(cand) } catch (_) {}
3382
+ if (cand) { absPath = cand; absFrom = 'props.resourceAddress' }
3383
+ }
3384
+ }
3385
+ if (!absPath && lastAbsPath) { absPath = lastAbsPath; absFrom = 'lastAbsPath(Actions 留下的)' }
3386
+ if (absPath !== pathSaid) {
3387
+ pathSaid = absPath
3388
+ log('本页文档路径 = ' + JSON.stringify(absPath) + ' | 来源 = ' + (absFrom || '(一条都没拿到 ✗)')
3389
+ + ' | resourceAddress = ' + JSON.stringify(props && props.resourceAddress)
3390
+ + ' | fileInfo.value 键 = ' + (function () {
3391
+ try { var fv2 = fileInfo && fileInfo.value; return fv2 ? Object.keys(fv2).join(',') : '(空)' } catch (_) { return '?' }
3392
+ })())
3393
+ // ⚠️ 2026-10-24:这里原来会登记 window.__ccvResourceTemplate ✗
3394
+ // 那是给"右侧栏清单点一下跳文档"用的 ✓ 但它有**先有鸡还是先有蛋**的问题 ✗:
3395
+ // 没打开过任何文档 → 模板永远没有 → 清单永远点不动 ✓(日志实证 ✓)
3396
+ // → 现已改成主模块**自己拼地址** ✓(照官方 fileAddressFor 算法 ✓
3397
+ // 见 src/client/editor-panel.js 的 ccvSessionFileAddress ✓)
3398
+ // → 这段登记**不再需要** ✗ 删掉少一处状态 ✓ 少一处错 ✓
3399
+ }
3400
+
3401
+ // ── 编辑用的 refs(无条件声明 ✓ hooks 规则)──
3402
+ var edRef = r.useRef(null) // 正文那个 contentEditable 元素
3403
+ var savedRef = r.useRef('') // 服务端给的原文 = 判断 dirty 的基准
3404
+ var renderedRef = r.useRef(null) // 上次真正渲染进去的 HTML(防"覆盖主人手打的内容")
3405
+ var renderedSeqRef = r.useRef(-1) // 上次渲染对应的 renderSeq(只有它变才允许重设 ✓)
3406
+ var pendingCaretRef = r.useRef(null) // 重设前记下的编辑状态(焦点/光标/滚动 ✓)
3407
+ // ★ 实例 id + 挂载/卸载留痕(2026-10-15 查"就像刷新了一下"✗)★
3408
+ // 如果重挂载 → 元素全新 → 焦点/光标/滚动全没 ✓ 与主人描述一致 ✓
3409
+ // 这个 id 只在挂载时分配一次 ✓ 日志里能直接看出"是不是换了个实例"✓
3410
+ var instIdRef = r.useRef(0)
3411
+ if (!instIdRef.current) instIdRef.current = ++bodyInstSeq
3412
+ var instId = instIdRef.current
3413
+ var saidRef = r.useRef(false) // 日志去重(dirty 状态只在变化时打一行)
3414
+ // ── 大纲 / 目录(2026-10-24 主人:「改成「左目录 + 右正文」」)──
3415
+ // ⚠️ **只读视图** ✓ 一个字符都不往正文里写 ✓ 不碰保存链路 ✓
3416
+ // ⚠️ 开合状态放模块级 navOpen:宿主每次保存都会重建这个组件 ✗
3417
+ // 放组件 state 的话,一保存目录就自己关了 ✓
3418
+ // ⚠️ 初始一律**收起** ✗ 手机竖屏屏幕窄,一上来就占 220px 会很难看 ✓
3419
+ // 量完尺寸(几十毫秒内)由下面的"自动态跟随"决定要不要展开 ✓
3420
+ var tocSt = r.useState(function () { return navOpen === null ? false : navOpen })
3421
+ var tocOn = tocSt[0], setTocOn = tocSt[1]
3422
+ var outSt = r.useState(function () { return [] }) // [{ lv, text, el }]
3423
+ var outline = outSt[0], setOutline = outSt[1]
3424
+ var actSt = r.useState(-1) // 当前高亮的第几条
3425
+ var actIdx = actSt[0], setActIdx = actSt[1]
3426
+ // ★ 悬浮展开(2026-10-27 补回):目录收起时鼠标停在左边留白 → 悬浮弹出,移出收起
3427
+ var peekSt = r.useState(false)
3428
+ var peekOn = peekSt[0], setPeekOn = peekSt[1]
3429
+ var outlineRef = r.useRef([]) // 给 scroll 回调读(避免闭包拿到旧值 ✗)
3430
+ outlineRef.current = outline
3431
+ // ★ 目录停靠 / 浮层(2026-10-24 主人)★
3432
+ // 规则:容器 **宽 ≥ 高** → 停靠(占布局,正文让出宽度)
3433
+ // 否则 → 浮层(盖在正文左侧,自带滚动条 ✓ 不挤压正文)
3434
+ // 为什么要分两种:右侧栏有时是窄高的(截图那种)✗
3435
+ // 窄高里再挤 208px 给目录 → 正文只剩一条缝 ✗
3436
+ var dockSt = r.useState(false) // ⚠️ 未量尺寸前先按"窄屏/浮层"处理 ✓(手机竖屏不占布局)
3437
+ var docked = dockSt[0], setDocked = dockSt[1]
3438
+
3439
+ // ★ 自动态跟随(2026-10-28 补回)★
3440
+ // navOpen === null(自动)时:量到宽屏 → 展开目录;窄屏(手机竖屏)→ 收起 ✓
3441
+ // 用户手动开/关过(navOpen 为 true/false)就不再干预 ✓
3442
+ r.useEffect(function () {
3443
+ if (navOpen !== null) return
3444
+ setTocOn(docked)
3445
+ if (docked) { try { if (tocRefreshFn) tocRefreshFn() } catch (_) {} }
3446
+ }, [docked])
3447
+
3448
+ // ─── props 变化 → 决定"要不要采纳成新缓冲"(2026-10-15 改地基)──────
3449
+ // 成熟模型(VS Code 的 etag / Google Docs 的 op 流)都遵循同一套:
3450
+ // · 编辑器缓冲是唯一真相 ✓ props 只是**输入** ✗ 不是渲染源
3451
+ // · 用**修订号**判断新旧 ✓ 不用文本比大小 ✗
3452
+ // · 外部改动只在"本地干净 + 不是输入法组合中"时采纳 ✓
3453
+ // 我们这里:fileInfo.value.version 就是那个修订号 ✓(日志实测有这个字段 ✓)
3454
+ r.useEffect(function () {
3455
+ var t = (props && props.content && props.content.text) || ''
3456
+ var propsVer = (fileInfo && fileInfo.value && fileInfo.value.version) || ''
3457
+ var prev0 = docGet(absPath)
3458
+ // ① 首次(还没有缓冲)→ 直接采纳 ✓
3459
+ if (!prev0 || typeof prev0.buf !== 'string') {
3460
+ savedRef.current = t
3461
+ saidRef.current = false
3462
+ docPatch(absPath, { buf: t, bufVer: propsVer, saved: t, md: t, dirty: false, override: null, renderSeq: (prev0 && prev0.renderSeq ? prev0.renderSeq : 0) + 1 })
3463
+ log('首次采纳内容为缓冲(fileVer=' + propsVer + ',' + t.length + ' 字)')
3464
+ return
3465
+ }
3466
+ // ② props 没变 → 什么都不做 ✓
3467
+ if (propsVer && prev0.bufVer === propsVer) return
3468
+ // ③ 这是我们自己保存的**回声** → 只更新修订号 ✓ **绝不重渲染** ✗
3469
+ // 两种情形都算回声:
3470
+ // a) 与我们缓冲**一字不差** ✓
3471
+ // b) **刚保存过的 5 秒窗口内、长度又相近** ✓
3472
+ // ⚠️ 为什么不能只比文本 ✗:文件监听回传时常带格式规整
3473
+ // (尾换行 / 空行 / 列表标记 ✓)→ 文本对不上 ✗ → 被当成外部改动 ✗
3474
+ // → renderSeq +1 → **整篇重设 = 就像刷新了一下** ✗✗
3475
+ // (2026-10-15 主人现场:保存后闪一下、焦点丢了、没法接着打字 ✓)
3476
+ // ⚠️ 为什么加"长度相近"✗:真·外部改动(AI 改文档)长度会明显不同 ✓
3477
+ // 所以它照样会被采纳 ✓ 不会被这条挡住 ✓
3478
+ var ownAge = Date.now() - ownWriteAt
3479
+ var looksLikeEcho = false
3480
+ if (ownWriteTxt && ownAge >= 0 && ownAge < 5000) {
3481
+ var la = t.length, lb = String(ownWriteTxt).length
3482
+ looksLikeEcho = Math.abs(la - lb) <= Math.max(20, lb * 0.02)
3483
+ }
3484
+ if (t === prev0.buf || looksLikeEcho) {
3485
+ savedRef.current = t
3486
+ docPatch(absPath, { bufVer: propsVer, saved: t, justSaved: null })
3487
+ if (t !== prev0.buf) {
3488
+ log('props 回声(回传 ' + t.length + ' 字 / 我们写的 ' + String(ownWriteTxt).length
3489
+ + ' 字,' + Math.round(ownAge / 1000) + ' 秒前)→ 只更修订号,不重渲染 ✓')
3490
+ }
3491
+ return
3492
+ }
3493
+ // ④ 有本地未保存改动 / 输入法组合中 → **不采纳** ✓(留给冲突路径 / 组合结束 ✓)
3494
+ if (prev0.dirty || composing) {
3495
+ log('外部改动来了,但本地有未保存的改动(或正在打字)→ 先不采纳 ✓')
3496
+ return
3497
+ }
3498
+ // ⑤ 真·外部改动 + 本地干净 → 采纳 ✓(AI 改了文件 / 别的窗口改了 ✓)
3499
+ // renderSeq + 1 ✓ —— 这是"内容来自外部"的少数几个入口之一 ✓
3500
+ savedRef.current = t
3501
+ saidRef.current = false
3502
+ docPatch(absPath, {
3503
+ buf: t, bufVer: propsVer, saved: t, md: t, dirty: false, override: null,
3504
+ renderSeq: (prev0.renderSeq || 0) + 1,
3505
+ })
3506
+ log('采纳外部改动为缓冲(fileVer=' + propsVer + ',' + t.length + ' 字)')
3507
+ // ★★ 版本号必须跟着刷新(2026-10-15 修「永久版本冲突」)★★
3508
+ // 原来这里只更新内容与基准,**没更新 ver** ✗
3509
+ // 于是"别处写过这篇文档"之后:内容变新了 ✓ 版本号还停在旧值 ✗
3510
+ // → 之后每次保存都被判「版本冲突」✗ → **永久死锁** ✗✗
3511
+ // (主人现场:本地 v28 / 服务端 v33,一直撞到刷新页面为止 ✓)
3512
+ // 这里只对齐版本,不动内容(内容已经由 props 给了 ✓)
3513
+ var idNow = (prev0 && prev0.id) || canvasIdForPath(absPath)
3514
+ if (idNow) {
3515
+ fetch('/api/canvas/read?id=' + encodeURIComponent(idNow) + '&' + ccvSidQ(), { cache: 'no-store' })
3516
+ .then(function (x) { return x.json() })
3517
+ .then(function (d) {
3518
+ if (d && typeof d.version === 'number') {
3519
+ var cur2 = docGet(absPath)
3520
+ if (cur2 && cur2.ver !== d.version) {
3521
+ docPatch(absPath, { ver: d.version })
3522
+ log('内容变了 → 版本对齐到 v' + d.version + '(原来 v' + cur2.ver + ')')
3523
+ }
3524
+ }
3525
+ }).catch(function () {})
3526
+ }
3527
+ }, [absPath, (props && props.content && props.content.text) || ''])
3528
+
3529
+ // 正文文本(依赖数组要用它 —— 换文档/内容变了才重跑 ✓)
3530
+ var contentText = (props && props.content && props.content.text) || ''
3531
+ // ⚠️⚠️ **必须有依赖数组**(2026-10-15 修,第二个同类 bug ✗)
3532
+ // 原来没有第二个参数 → **每次渲染都跑** ✗ 而它每次都在做:
3533
+ // · loadCanvasMap() → 一次网络请求 ✗(渲染一次发一次 = 请求风暴 ✓)
3534
+ // · ensureViewerCss() → 注入样式 ✗
3535
+ // · setTimeout(400) → 每次渲染又起一个定时器 ✗(getComputedStyle 会强制排版 ✓)
3536
+ // 后果(主人现场):
3537
+ // · 「保存失败:服务端无响应」—— 请求风暴把服务端打爆 ✓
3538
+ // · 焦点乱跳(有时在画布、有时在聊天框)✗
3539
+ // · 连按 Ctrl+Z 失效 ✗
3540
+ r.useEffect(function () {
3541
+ ensureViewerCss()
3542
+ loadCanvasMap(function () {})
3543
+ publishInput(props, absPath, inputState)
3544
+ ensureListeners()
3545
+ // 往返自检(md → html → md):打开文档就量一次,差异写进日志
3546
+ roundTripCheck((props && props.content && props.content.text) || '', absPath)
3547
+ // ── 实测排版(2026-10-11 加)──
3548
+ // 主人报"引用块行距大"。我先猜"空段落"(错 ✗)、又猜"样式没生效"(也错 ✗)——
3549
+ // 日志证明 HTML 干净、样式也在。那就别再猜:**把浏览器算出来的值打出来**。
3550
+ // marginTop/Bottom、lineHeight、fontSize 是"最终生效"的值,谁压过谁一目了然。
3551
+ setTimeout(function () {
3552
+ try {
3553
+ var body = document.querySelector('[data-ccv-md-body="1"]')
3554
+ if (!body) { log('实测:找不到正文元素'); return }
3555
+ var bs = window.getComputedStyle(body)
3556
+ var bq = body.querySelector('blockquote')
3557
+ var bqs = bq ? window.getComputedStyle(bq) : null
3558
+ var ps = body.querySelectorAll('blockquote p')
3559
+ var rows = []
3560
+ for (var i = 0; i < Math.min(3, ps.length); i++) {
3561
+ var cs = window.getComputedStyle(ps[i])
3562
+ var r = ps[i].getBoundingClientRect()
3563
+ rows.push('#' + i + ' top=' + Math.round(r.top) + ' h=' + Math.round(r.height)
3564
+ + ' mt=' + cs.marginTop + ' mb=' + cs.marginBottom
3565
+ + ' lh=' + cs.lineHeight + ' fs=' + cs.fontSize)
3566
+ }
3567
+ log('实测排版:正文 fs=' + bs.fontSize + ' lh=' + bs.lineHeight
3568
+ + ' whiteSpace=' + bs.whiteSpace
3569
+ + ';blockquote lh=' + (bqs ? bqs.lineHeight : '—') + ' fs=' + (bqs ? bqs.fontSize : '—')
3570
+ + ';块内段落 ' + ps.length + ' 个 → ' + (rows.join(' | ') || '(没有)'))
3571
+ } catch (e) { log('实测排版失败: ' + (e && e.message)) }
3572
+ }, 400)
3573
+ }, [absPath, contentText]) // ★★ 就是这里(原来只有 `})` ✗ → 每次渲染都跑 → 请求风暴)
3574
+
3575
+ // ── 之后才允许提前返回 ──
3576
+ var content = props ? props.content : null
3577
+ var kind = content && content.kind
3578
+ // ★ 输入法组合期间**绝不重设 innerHTML** ✗(重设 = 打断输入法 ✗ 拼音白打 ✓)
3579
+ // 与"拖动期间不覆盖"同一个道理 ✓(2026-10-15)
3580
+ var boxStyle = {
3581
+ height: '100%', overflow: 'auto', boxSizing: 'border-box',
3582
+ // ⚠️ 2026-10-27:内距改成**固定值** ✗ 不随目录开合变
3583
+ // 之前是条件式(停靠 40 / 浮层 让宽 / 其余 36)→ 切换目录后会丢 ✓
3584
+ // 主人报过两次「开一次目录再关掉,间距又没了」→ 现在只用一个值 ✓
3585
+ // 让位(浮层盖住正文时)改由**外层 shell** 负责 ✓ 见下面的 data-ccv-shell-pad
3586
+ padding: '18px 24px 18px 20px', lineHeight: 1.75,
3587
+ fontSize: '14px', wordBreak: 'break-word',
3588
+ // ⚠️ 必须显式声明 normal。2026-10-11 实测定位到的那一个:
3589
+ // 渲染器产出的 HTML 在标签之间带换行(`</p>\n<p>`,见 renderBlocks 的 out.join("\n")),
3590
+ // 而这里**继承了查看器容器的 white-space: pre-wrap** → 每个换行被当成真换行渲染出来
3591
+ // → 段落之间凭空多出一整行。
3592
+ // 实测数字:段落间距 38px,而边距只有 7px —— 38 ≈ 24.5(一整行) + 7 + 7 ✓ 正好对上。
3593
+ // (面板没这毛病:编辑区里浏览器会把标签间的空白规整掉。)
3594
+ whiteSpace: 'normal'
3595
+ }
3596
+ var text = (content && typeof content.text === 'string') ? content.text
3597
+ : ((content && content.pages) || []).map(function (p) { return (p && p.text) || '' }).join('')
3598
+ // ★★ 渲染源 = 【缓冲】而不是 props(2026-10-15 改地基)★★
3599
+ // 原来 text 直接来自 props.content.text ✗ → 每次保存后 props 一变
3600
+ // → React 就要重设 innerHTML ✗ → 我们只好加一堆守卫去拦(dirty / staleProps /
3601
+ // echoOwnSave …)✓ 那就是"自动保存带来一堆问题"的根 ✓
3602
+ // 成熟软件(Google Docs 的文档模型 / VS Code 的编辑缓冲)都是:
3603
+ // **编辑器自己的缓冲是唯一真相** ✓ props 只是**输入** ✓ 不是渲染源 ✗
3604
+ // 于是:我们自己的保存回声**根本不会引起重渲染** ✓(buf 没变 ✓)
3605
+ // 只有"真·外部改动 + 本地干净"才会采纳并重渲染 ✓
3606
+ var dsBodyBuf = docGet(absPath)
3607
+ if (dsBodyBuf && typeof dsBodyBuf.buf === 'string') text = dsBodyBuf.buf
3608
+ // 撤销/重做刚发生时,正文先按 override 显示(别等文件监听 ✗ —— 那是延迟的来源之一)
3609
+ var dsBody = docGet(absPath)
3610
+ if (dsBody && typeof dsBody.override === 'string') text = dsBody.override
3611
+
3612
+ // 头几次渲染把现场写进日志(别刷屏:只记前 3 次)
3613
+ if (bodySaid < 3) {
3614
+ bodySaid++
3615
+ log('正文组件第 ' + bodySaid + ' 次被调用:'
3616
+ + 'React=' + (h ? '有' : '没有')
3617
+ + ' props键=' + (props ? Object.keys(props).join(',') : '(无)')
3618
+ + ' kind=' + (kind || '(无 content)')
3619
+ + ' 正文字数=' + text.length
3620
+ + ' 路径=' + (absPath || '(没拿到)'))
3621
+ }
3622
+
3623
+ // 任何意外都不许变成空白:把话说明白
3624
+ try {
3625
+ if (kind !== 'text') {
3626
+ return h('div', { 'data-ccv-md-body': '1', style: boxStyle },
3627
+ kind ? '(话布渲染器:暂不认这种内容 ' + kind + ')' : '(话布渲染器:没拿到文件内容)')
3628
+ }
3629
+ // ─── 重设之后:把编辑状态还回去(2026-10-15)─────────────────────
3630
+ // 与上面"重设前接住"配对 ✓ —— 这是同一个总闸的另一半 ✓
3631
+ // 还原:焦点 ✓ 字符偏移处的光标 ✓ 滚动位置 ✓
3632
+ // ⚠️ 只有**重设前编辑区确实有焦点**时才抢焦点 ✓(否则会打断你正在别处做的事 ✗)
3633
+ r.useEffect(function () {
3634
+ var p = pendingCaretRef.current
3635
+ if (!p) return
3636
+ pendingCaretRef.current = null
3637
+ try {
3638
+ var ed = edRef.current
3639
+ if (!ed) return
3640
+ if (p.had) {
3641
+ try { ed.focus({ preventScroll: true }) } catch (_) { try { ed.focus() } catch (_2) {} }
3642
+ setCaretOffset(ed, p.off)
3643
+ }
3644
+ try {
3645
+ var box = ccvScrollBox(ed)
3646
+ if (box && typeof p.scroll === 'number') box.scrollTop = p.scroll
3647
+ } catch (_) {}
3648
+ log('重设后已还原编辑状态:焦点=' + (p.had ? '还回去了 ✓' : '本来就没有(不抢)✓')
3649
+ + ' 光标偏移=' + p.off + ' 滚动=' + p.scroll)
3650
+ } catch (_) {}
3651
+ }, [renderedSeqRef.current])
3652
+
3653
+ // ─── 一次"正常输入"的处理(组合结束后也走它 ✓)──────────────────
3654
+ // 从原来的 onInput 里原样搬出来 ✓ 逻辑一个字没改 ✓
3655
+ var handleEditInput = function () {
3656
+ try {
3657
+ var el = edRef.current
3658
+ if (!el) return
3659
+ // ★ 记下"最后一次输入的时刻"✓(saveDoc 靠它确认"你现在没在打字"✗)
3660
+ // 2026-10-17 主人点出的窗口:「卡着第五秒打字」✓
3661
+ lastInputAt = Date.now()
3662
+ // 查找条开着时:内容变了要重新数 + 重画高亮 ✓(rAF 节流 ✓)
3663
+ findSyncOnEdit()
3664
+ // ★ A4:按顶层子元素分块转 ✓(块边界 = DOM 的顶层结构 ✓ 不再重新推 ✗)
3665
+ var md = editorToMd(el)
3666
+ if (typeof md !== 'string') { log('取编辑内容失败:htmlToMd 没借出来'); return }
3667
+ var dirty = md !== savedRef.current
3668
+ if (dirty !== saidRef.current) {
3669
+ saidRef.current = dirty
3670
+ log(dirty ? '有未保存的改动(' + md.length + ' 字)' : '改动已回到基准(' + md.length + ' 字)')
3671
+ }
3672
+ docPatch(absPath, { dirty: dirty, md: md, saved: savedRef.current, buf: md })
3673
+ // ★ 自动保存 + 草稿(成熟文档软件的做法,见 afterEdit 的注释)
3674
+ // 有改动才排;回到基准(用户自己撤回去了)就不用存 ✓
3675
+ if (dirty) {
3676
+ var dsNow2 = docGet(absPath)
3677
+ afterEdit(absPath, md, (dsNow2 && dsNow2.id) || '', (dsNow2 && dsNow2.ver) || 0)
3678
+ } else {
3679
+ // ★ 内容**回到基准**了(用户自己按 Ctrl+Z 撤回去了 / 或撤到了头)→
3680
+ // 那份草稿就作废了,必须丢掉 ✓
3681
+ // 不丢的话:下次一查草稿 → 基准对得上 → "恢复"回那次编辑 ✗✗
3682
+ // (主人 2026-10-15 的现场:改成斜体 → 马上 Ctrl+Z → 提示"恢复了未保存的
3683
+ // 草稿" → 内容又变回斜体 ✗ 根因就在这儿)
3684
+ var dsNow3 = docGet(absPath)
3685
+ try {
3686
+ if (dsNow3 && dsNow3.id && window.__ccvKit && window.__ccvKit.draftDrop) {
3687
+ window.__ccvKit.draftDrop(dsNow3.id)
3688
+ log('内容回到基准 → 草稿作废丢掉(' + dsNow3.id + ')')
3689
+ }
3690
+ } catch (_) {}
3691
+ }
3692
+ } catch (e) { log('onInput 出错: ' + (e && e.message)) }
3693
+ }
3694
+
3695
+ // ★★ mdToHtml 结果缓存(2026-10-15,主人报"自动保存会卡我拉表格")★★
3696
+ // md2html 是**整篇 markdown 解析** ✓ 6KB 文档也要几毫秒 ✗
3697
+ // 而 Body 会因为**保存状态**变化就重渲染 ✗(saving:true/false 也发通知 ✓)
3698
+ // → 每次重渲染都白跑一遍解析 ✗ 拖表格时正好撞上 = 卡 ✓✓
3699
+ // 修:只有正文真的变了才重算 ✓(缓存放在模块级,按 路径+正文 做键 ✓)
3700
+ var html = null
3701
+ var ck = absPath + '\u0000' + text
3702
+ if (mdCache.key === ck) {
3703
+ html = mdCache.html
3704
+ } else {
3705
+ html = md2html(text)
3706
+ mdCache = { key: ck, html: html }
3707
+ }
3708
+ // ⚠️ 有未保存的改动时**不覆盖正文** —— 否则 AI(或别的窗口)改了文件之后,
3709
+ // 主人手打的内容会凭空消失 ✗。做法:继续用上次渲染过的 HTML,
3710
+ // React 看到字符串没变就不会重设 innerHTML ✓ 光标和内容都保住 ✓
3711
+ //
3712
+ // ★★ 还要防"刚保存成功那一瞬间"(2026-10-15 主人报「删了表格又给我恢复了」)★★
3713
+ // 链条:删表格 → dirty=true(守卫生效 ✓)→ 自动保存成功 → dirty=false ✗
3714
+ // → 而 props.content.text 还是**旧的**(文件监听还没追上 ✓)
3715
+ // → 守卫失效 → 用旧 props 算出的 HTML(带表格 ✗)被重新贴回去 ✗✗
3716
+ // 判据:props 的正文 ≠ 我们**刚写进服务端的那份**(dsNow.saved)→ props 是旧的 ✓
3717
+ // ⚠️ 这不会挡住"外部改动"✗ —— 因为内容变化那个 effect 会在 props 变化时
3718
+ // 把 saved 同步成新 props ✓(于是两者相等 → 不再算 stale → 正常显示 ✓)
3719
+ // ★★★ 重渲染由**我们显式决定**(2026-10-15 第三次修,前两次都没根治)★★★
3720
+ // 前两版都在"猜"内容变没变(dirty / staleProps / echoOwnSave 四道守卫 ✗)
3721
+ // 而只要**有一次猜错** → React 重设 innerHTML → 闪一下 ✗
3722
+ // → 你正按"插入表格"时那一下正好落在 execCommand 中间 → 插不进去 ✗
3723
+ // → 打字时那一下打断输入法 ✗;保存时那一下清空原生撤销栈 ✗
3724
+ //
3725
+ // 成熟做法(Google Docs 的 op 流 / VS Code 的缓冲):
3726
+ // **编辑器 DOM 就是真相** ✓ 只有"内容来自外部"时才允许重设 ✓
3727
+ // 实现:store 里放一个 renderSeq ✓
3728
+ // · 编辑区自己改的(打字 / 表格操作)→ **不动 seq** ✓ → htmlToShow 保持不变 ✓
3729
+ // · 内容来自外部(首次加载 / 外部改动 / 撤销重做 / 草稿恢复)→ **seq + 1** ✓
3730
+ // 于是"猜"这件事彻底没有了 ✓ 四道守卫也全都不需要了 ✓
3731
+ var seqNow = dsBodyBuf ? (dsBodyBuf.renderSeq || 0) : 0
3732
+ var htmlToShow = renderedRef.current
3733
+ if (renderedSeqRef.current !== seqNow || !renderedRef.current) {
3734
+ log('重设正文(实例#' + instId + ' renderSeq=' + renderedSeqRef.current + '→' + seqNow
3735
+ + ',' + html.length + ' 字节 HTML)')
3736
+ // ★★ 重设之前先把编辑状态接住(2026-10-15)★★
3737
+ // 重设 innerHTML = 把编辑区整个重建 ✗ 会毁掉:
3738
+ // 焦点 / 光标 / 滚动位置 / 输入法组合 / 浏览器原生撤销栈 ✗
3739
+ // (主人现场:Ctrl+Z 丢焦点要再点一下 ✓ 插表格被闪掉 ✓ 撤销历史为空 ✓
3740
+ // 这些**是同一件事** ✓ 不是三个 bug ✗)
3741
+ // 所以在这**唯一的总闸**上一次性接住:记下焦点与字符偏移 ✓ 重建后还回去 ✓
3742
+ try {
3743
+ pendingCaretRef.current = {
3744
+ had: !!(edRef.current && document.activeElement === edRef.current),
3745
+ off: caretOffset(edRef.current),
3746
+ scroll: (function () {
3747
+ try {
3748
+ var box = edRef.current && edRef.current.parentElement
3749
+ return box ? box.scrollTop : 0
3750
+ } catch (_) { return 0 }
3751
+ })(),
3752
+ }
3753
+ } catch (_) { pendingCaretRef.current = null }
3754
+ renderedSeqRef.current = seqNow
3755
+ renderedRef.current = html
3756
+ htmlToShow = html
3757
+ }
3758
+ if (!html) {
3759
+ return h('div', { 'data-ccv-md-body': '1', style: boxStyle },
3760
+ '(话布渲染器:正文是空的 —— 共 ' + text.length + ' 字)')
3761
+ }
3762
+ // 去掉标签之间的换行(第二道保险,第一道是上面的 whiteSpace: normal)。
3763
+ // ⚠️ 只吃 `>` 和 `<` 之间的换行 —— <pre> 里的换行是**内容**,两边不是 `>`/`<`,
3764
+ // 所以不会被误删(`<code>a\nb</code>` 的换行在 a 和 b 之间,安全)。
3765
+ html = html.replace(/>\s*\n\s*</g, '><')
3766
+ // 头一次渲染把**产物本身**打出来(2026-10-11 加):
3767
+ // 主人报"引用块行距还是大",我猜了"空段落"但改完没用 —— 猜错了两轮。
3768
+ // 所以直接把事实打出来:HTML 长什么样、空段落有几个、样式表里有没有那两条规则。
3769
+ // 项目里的老规矩:**先让证据说话,再改**。
3770
+ if (bodySaid <= 3) {
3771
+ try {
3772
+ var emptyP = (html.match(/<p>\s*<\/p>/g) || []).length
3773
+ var allP = (html.match(/<p[ >]/g) || []).length
3774
+ var st = document.getElementById('ccv-viewer-md-style')
3775
+ log('正文 HTML:空段落 ' + emptyP + ' / 段落共 ' + allP
3776
+ + ';样式表=' + (st ? (st.textContent.length + ' 字节, 含 p:empty=' + (st.textContent.indexOf('p:empty') >= 0)) : '没有'))
3777
+ log('HTML 开头 200 字:' + html.slice(0, 200).replace(/\n/g, '⏎'))
3778
+ } catch (_) {}
3779
+ }
3780
+ // ── 目录栏(只读视图 ✓ 不碰正文 ✓)—— 样式照豆包大纲 ──
3781
+ var tocEl = null
3782
+ // ★ 悬浮展开(2026-10-27 补回):peekOn 只在"目录收起时鼠标停在左边留白"期间为真
3783
+ if (tocOn || peekOn) {
3784
+ ensureTocCss()
3785
+ // ★ ① 顶部标题和第一条重复 ✗(主人 2026-10-24 第二条意见)★
3786
+ // 文档第一个 h1 就是"文档标题" ✓ 已经放在顶部了 ✓
3787
+ // → 列表里**跳过那一条** ✓ 不再出现两遍标题 ✓
3788
+ var srcOutline = outline || []
3789
+ var headTitle = ''
3790
+ var listStart = 0
3791
+ try {
3792
+ var first0 = srcOutline[0]
3793
+ if (first0 && first0.lv === 1) {
3794
+ headTitle = first0.text
3795
+ listStart = 1 // ← 跳过第一条 ✓
3796
+ }
3797
+ } catch (_) {}
3798
+ var items = srcOutline.slice(listStart).map(function (it, i0) {
3799
+ var i = i0 + listStart // 真实下标(高亮要对得上 ✓)
3800
+ var isAct = (i === actIdx)
3801
+ var cls = 'ccv-toc-item ccv-l' + Math.min(4, Math.max(1, it.lv)) + (isAct ? ' is-active' : '')
3802
+ return h('div', {
3803
+ key: 'toc' + i,
3804
+ className: cls,
3805
+ title: it.text,
3806
+ onClick: function () {
3807
+ try {
3808
+ // 滚到那个标题 ✓ 不抢焦点、不动光标 ✗
3809
+ var sb = ccvScrollBox(edRef.current)
3810
+ if (sb) {
3811
+ var r0 = it.el.getBoundingClientRect()
3812
+ var rb = sb.getBoundingClientRect()
3813
+ sb.scrollTop = sb.scrollTop + (r0.top - rb.top) - 8
3814
+ } else {
3815
+ it.el.scrollIntoView({ block: 'start' })
3816
+ }
3817
+ setActIdx(i)
3818
+ log('目录:跳到第 ' + (i + 1) + ' 条「' + it.text.slice(0, 24) + '」')
3819
+ } catch (_) {
3820
+ try { it.el.scrollIntoView() } catch (_2) {}
3821
+ }
3822
+ },
3823
+ }, it.text)
3824
+ })
3825
+ // 顶部标题:优先用文档里的第一个 h1(= 文档标题 ✓),否则用文件名 ✓
3826
+ var docTitle = headTitle
3827
+ if (!docTitle) {
3828
+ try {
3829
+ docTitle = String(absPath || '').replace(/\\/g, '/').split('/').pop().replace(/\.md$/i, '')
3830
+ } catch (_) {}
3831
+ }
3832
+ tocEl = h('div', {
3833
+ 'data-ccv-toc': '1',
3834
+ 'data-ccv-toc-mode': tocOn ? (docked ? 'dock' : 'overlay') : 'peek',
3835
+ className: 'ccv-toc',
3836
+ // 悬浮态:鼠标一移出就收起 ✓(固定展开时绝不能挂 ✗ 否则一动就没了)
3837
+ onMouseLeave: tocOn ? undefined : function () {
3838
+ try { setPeekOn(false); log('目录:悬浮收起') } catch (_) {}
3839
+ },
3840
+ style: tocOn ? (docked ? {
3841
+ // ── 停靠:占布局 ✓ 正文让出宽度 ✓(豆包那版没有边框,靠留白分隔 ✓)──
3842
+ flex: '0 0 ' + NAV_W + 'px', width: NAV_W + 'px',
3843
+ } : {
3844
+ // ── 浮层:贴正文左侧 ✓ 自带滚动条 ✓ 不挤压正文 ✓ ──
3845
+ // ⚠️ 2026-10-24 主人说「你的目录有边框」✗
3846
+ // 停靠模式本来就没边框 ✓ 所以那条"边"来自**浮层的阴影** ✗
3847
+ // → 阴影、圆角全部去掉 ✓ 只留底色,靠留白分隔(与豆包一致 ✓)
3848
+ position: 'absolute', left: 0, top: 0, bottom: 0, zIndex: 40,
3849
+ width: NAV_W_OVER + 'px',
3850
+ background: 'var(--dsw-alias-bg-layer-1,rgba(30,30,30,.96))',
3851
+ }) : {
3852
+ // ── 悬浮(收起状态下的临时展开):盖在正文上 ✓ 移出即收 ✓ ──
3853
+ position: 'absolute', left: 0, top: 0, bottom: 0, zIndex: 41,
3854
+ width: NAV_W_OVER + 'px',
3855
+ background: 'var(--dsw-alias-bg-layer-1,rgba(30,30,30,.97))',
3856
+ },
3857
+ },
3858
+ h('div', { className: 'ccv-toc-head' },
3859
+ h('div', {
3860
+ className: 'ccv-toc-collapse',
3861
+ title: tocOn ? '收起目录' : '关闭(回到正文)',
3862
+ onClick: function () {
3863
+ try {
3864
+ // ⚠️ 2026-10-28 主人:「手机上点留白区弹出悬浮目录,关不掉」
3865
+ // 触屏没有 mouseleave ✗ 所以「移出即收」在手机上不成立 ✓
3866
+ // → 悬浮态下这个「«」就是**关闭键** ✓ 只关悬浮、不改 navOpen ✗
3867
+ if (!tocOn) { setPeekOn(false); log('目录:关闭悬浮(点«)') ; return }
3868
+ navOpen = false; navEmit(); log('目录:收起')
3869
+ } catch (_) {}
3870
+ },
3871
+ }, '«'),
3872
+ h('div', { className: 'ccv-toc-title', title: docTitle }, docTitle || '目录')),
3873
+ // ⚠️ 判空要用"去掉标题那条之后"的条数 ✓
3874
+ // 否则:文档只有一个大标题时 → 顶部有标题、下面却写"没有标题"✗
3875
+ (srcOutline.length > listStart) ? h('div', null, items)
3876
+ : h('div', { className: 'ccv-toc-empty' }, '这篇没有小标题(用 ## 开头的行会出现在这里)'))
3877
+ } else {
3878
+ // ★ 收起态(2026-10-27 补回悬停展开)★
3879
+ // 主人要求:鼠标移到左边留白里 → 目录悬浮弹出;移出 → 收起 ✓
3880
+ // ⚠️ 热区 = **整条左边留白**(CSS 里 width:20px / top:0 / bottom:0)
3881
+ // 不是只有那个小图标能触发 ✗ 那样很难瞄 ✓
3882
+ ensureTocCss()
3883
+ tocEl = h('div', {
3884
+ className: 'ccv-toc-reopen',
3885
+ title: '鼠标移上去看目录(点击可固定展开)',
3886
+ 'data-ccv-toc-reopen': '1',
3887
+ onClick: function () { try { navOpen = true; navEmit(); log('目录:固定展开') } catch (_) {} },
3888
+ onMouseEnter: function () {
3889
+ try {
3890
+ setPeekOn(true)
3891
+ if (tocRefreshFn) tocRefreshFn() // 展开前重算一次 ✓ 别显示旧标题 ✓
3892
+ log('目录:悬浮展开')
3893
+ } catch (_) {}
3894
+ },
3895
+ },
3896
+ // 三条横线仍在最上面当视觉提示 ✓(主人 2026-10-24:「应该是一一的横线」)
3897
+ h('i', null), h('i', null), h('i', null))
3898
+ }
3899
+ var edEl = h('div', {
3900
+ 'data-ccv-md-body': '1',
3901
+ // ⚠️ 2026-10-28 主人:「手机上点留白区弹出悬浮目录,关不掉」
3902
+ // 触屏没有 mouseleave ✗ 所以补一条"点正文任意处就关掉悬浮目录" ✓
3903
+ // (桌面端也成立,且不抢焦点、不阻止点击 ✓)
3904
+ onMouseDown: function () { try { if (peekOn) setPeekOn(false) } catch (_) {} },
3905
+ onTouchStart: function () { try { if (peekOn) setPeekOn(false) } catch (_) {} },
3906
+ // ⚠️ 2026-10-27:正文**不再有任何条件式内距** ✗
3907
+ // 之前这里有两档 paddingLeft(停靠 40 / 浮层 让宽)→
3908
+ // 切换目录后内距会"丢"(主人报过两次)✓
3909
+ // 现在让位/留白统一由**外层 shell**(data-ccv-shell-pad)负责 ✓
3910
+ style: Object.assign({},
3911
+ boxStyle,
3912
+ { flex: '1 1 auto', minWidth: 0, minHeight: 0 }),
3913
+ // ── 编辑(2026-10-11 加,"把话布搬到查看器"的核心)──────────
3914
+ // contentEditable + 面板那套 mdToHtml/htmlToMd —— 两端共用同一份转换 ✓
3915
+ // ⚠️ 用 dangerouslySetInnerHTML 而不是手动 setInnerHTML:React 只在
3916
+ // **字符串变了**时才重设 innerHTML ✓,所以打字过程中(text prop 没变)
3917
+ // 不会被 React 冲掉光标 ✓(面板那边是手动管的,因为它的重渲染更频繁)
3918
+ contentEditable: true,
3919
+ suppressContentEditableWarning: true,
3920
+ spellCheck: false,
3921
+ ref: function (el) {
3922
+ edRef.current = el
3923
+ liveEditor = el // 划词栏用(老面板的 editorRef.current 对应物 ✓)
3924
+ bindTableResize(el) // 表格列宽拖拽(一次性绑定,委托在根上 ✓)
3925
+ // ★ 组件被宿主重建之后:把编辑状态接回来(2026-10-15)★
3926
+ // 宿主在文件变化时会卸载重装这个组件 ✗ → 元素全新 → 焦点/光标没了 ✓
3927
+ // 模块级 liveEditState 里存着"最后已知状态" ✓ 这里还回去 ✓
3928
+ // ⚠️ 只在 5 秒内、且原本确实有焦点时才抢 ✓(免得抢你正在别处做的事 ✗)
3929
+ try {
3930
+ var ls = liveEditState
3931
+ if (el && ls && ls.hadFocus && (Date.now() - ls.at) < 5000) {
3932
+ setTimeout(function () {
3933
+ try {
3934
+ var ed2 = edRef.current
3935
+ if (!ed2) return
3936
+ try { ed2.focus({ preventScroll: true }) } catch (_) { try { ed2.focus() } catch (_2) {} }
3937
+ setCaretOffset(ed2, ls.offset)
3938
+ var sb2 = ccvScrollBox(ed2); if (sb2) sb2.scrollTop = ls.scroll
3939
+ log('组件被宿主重建过 → 已把焦点与光标接回来 ✓(偏移 ' + ls.offset + ')')
3940
+ } catch (_) {}
3941
+ }, 0)
3942
+ }
3943
+ } catch (_) {}
3944
+ },
3945
+ onBlur: function () { flushEdit(absPath) }, // 失焦即存 ✓(别让最后一段输入卡在防抖里 ✗)
3946
+ onKeyDown: function (e) { onEditKeyDown(e, absPath) }, // 快捷键(见 onEditKeyDown)
3947
+ onContextMenu: function (e) { // 右键菜单(阶段 4 搬过来的 ✓)
3948
+ try {
3949
+ if (!insideBody(e.target)) return
3950
+ e.preventDefault()
3951
+ // 右键那一刻先把选区存下来 ✓(菜单点完要用它)
3952
+ var sel = document.getSelection()
3953
+ if (sel && sel.rangeCount && String(sel.toString() || '').trim()) {
3954
+ savedRange = sel.getRangeAt(0).cloneRange()
3955
+ updateSelFormats(sel)
3956
+ }
3957
+ showContextMenu(e.clientX, e.clientY)
3958
+ } catch (err) { log('右键菜单出错: ' + (err && err.message)) }
3959
+ },
3960
+ onCompositionStart: function () {
3961
+ // ─── 输入法组合保护(2026-10-15,主人报「打一长串拼音还没选字,自动保存就打断我」)───
3962
+ // contentEditable 里 **input 事件在拼写过程中就会触发** ✗(拼音还没上屏 ✓)
3963
+ // 我们把它当真实编辑 → 脏标记 + 草稿 + 自动保存 ✓
3964
+ // → 保存回来 docPatch → 通知 → Body 重渲染 ✗ → React 重设 innerHTML ✗✗
3965
+ // → **输入法组合被打断**(拼音白打 / 直接上屏 ✗)
3966
+ // 做法:组合期间**一律不当编辑** ✗ 组合结束再补一次正常处理 ✓
3967
+ composing = true
3968
+ log('输入法:开始组合(期间不标记脏、不排保存 ✓)')
3969
+ },
3970
+ onCompositionEnd: function () {
3971
+ composing = false
3972
+ log('输入法:组合结束 → 补一次正常处理 ✓')
3973
+ try { handleEditInput() } catch (_) {}
3974
+ },
3975
+ onInput: function (e) {
3976
+ // 组合期间(以及浏览器标了 isComposing 的)→ 完全不动 ✓
3977
+ try {
3978
+ if (composing || (e && e.isComposing)) return
3979
+ } catch (_) {}
3980
+ handleEditInput()
3981
+ },
3982
+ dangerouslySetInnerHTML: { __html: htmlToShow }
3983
+ })
3984
+ // ⚠️ 外层容器的形状**永远一样** ✓ 只让目录这个孩子出现/消失 ✓
3985
+ // ("开目录才包一层"会让正文重新挂载 → 丢焦点 ✗)
3986
+ return h('div', {
3987
+ 'data-ccv-md-shell': '1',
3988
+ // position:relative 让浮层模式能定位到左侧 ✓(停靠模式不受影响 ✓)
3989
+ // 留白三档:停靠开着 0 ✓(目录自己占位)/浮层开着 让宽+12 ✓/关着 16 ✓
3990
+ 'data-ccv-shell-pad': tocOn ? (docked ? '0' : '1') : '2',
3991
+ style: {
3992
+ position: 'relative', display: 'flex', height: '100%', minHeight: 0, minWidth: 0,
3993
+ alignItems: 'stretch', boxSizing: 'border-box',
3994
+ paddingLeft: !tocOn ? '16px' : (docked ? 0 : ((NAV_W_OVER + 12) + 'px')),
3995
+ },
3996
+ }, tocEl, edEl)
3997
+ } catch (e) {
3998
+ var msg = (e && e.message) || String(e)
3999
+ log('正文组件出错: ' + msg)
4000
+ return h('div', { 'data-ccv-md-body': '1', style: boxStyle },
4001
+ '(话布渲染器出错:' + msg + ')')
4002
+ }
4003
+ }
4004
+
4005
+ // ─── 注册进查看器 ──────────────────────────────────────────
4006
+ function boot(ctx) {
4007
+ if (booted) return
4008
+ if (!ctx) return
4009
+
4010
+ var reg = null
4011
+ try { reg = (typeof ctx.get === 'function') ? ctx.get('documentPreviews') : ctx.documentPreviews } catch (_) {}
4012
+ if (!reg || typeof reg.register !== 'function') {
4013
+ // 服务拿不到就**安静放弃**:这个 DSH 版本没有这个口子,不该影响任何现有功能
4014
+ log('v' + VERSION + ' 取不到 documentPreviews 服务(此版本可能不支持),本轮放弃接管查看器')
4015
+ return
4016
+ }
4017
+ booted = true
4018
+
4019
+ // ① 声明:接管哪些后缀、外部实现优先、内容由框架按 text-pages 模式加载
4020
+ try {
4021
+ ctx.effect(function () {
4022
+ return reg.register({
4023
+ id: ID,
4024
+ extensions: ['md', 'markdown'],
4025
+ priority: 'extension', // ★ 外部实现优先 —— 内置那版会让位(但仍留作备选)
4026
+ title: function () { return TITLE }, // 「话布文档」(见顶部 TITLE 的说明)
4027
+ loading: 'text-pages',
4028
+ wrap: true
4029
+ })
4030
+ })
4031
+ log('v' + VERSION + ' 已声明接管 .md(id=' + ID + ',priority=extension)')
4032
+ } catch (e) {
4033
+ log('v' + VERSION + ' 声明失败: ' + (e && e.message))
4034
+ return
4035
+ }
4036
+
4037
+ // ② 正文组件:注册进 keyed slot,key 必须与上面的 id 一致
4038
+ try {
4039
+ ctx.effect(function () {
4040
+ return ctx.slots.inject('sidebar.right.tab.document', function () {
4041
+ return ctx.slots.register({ name: 'sidebar.right.tab.document', key: ID }, Body)
4042
+ })
4043
+ })
4044
+ log('v' + VERSION + ' 已注册正文组件(sidebar.right.tab.document,key=' + ID + ')')
4045
+ } catch (e) {
4046
+ log('v' + VERSION + ' 注册正文组件失败: ' + (e && e.message))
4047
+ }
4048
+
4049
+ // ③ 工具栏按钮(撤销 / 重做):挂到 list 槽 sidebar.right.tab.document.actions
4050
+ // ⚠️ list 槽用 **id**(不是 key ✗)—— 契约来自 Slots.listSubTree:
4051
+ // registration = id(必填) / order / label ✓
4052
+ // id 用自己的,别撞官方的(撞上会顶掉人家的格子 ✗ 官方那个是 open-in-app)
4053
+ try {
4054
+ ctx.effect(function () {
4055
+ return ctx.slots.inject('sidebar.right.tab.document.actions', function () {
4056
+ return ctx.slots.register({ name: 'sidebar.right.tab.document.actions', id: 'collab-canvas-undo', order: 10 }, Actions)
4057
+ })
4058
+ })
4059
+ log('v' + VERSION + ' 已注册工具栏按钮(撤销/重做)')
4060
+ } catch (e) {
4061
+ log('v' + VERSION + ' 注册工具栏按钮失败: ' + (e && e.message))
4062
+ }
4063
+ }
4064
+
4065
+ // 主模块 apply(ctx) 会调它;也可能本模块先加载完、主模块后到 —— 两条路都要通
4066
+ window.__ccvViewerBoot = boot
4067
+ if (window.__ccvCtx) boot(window.__ccvCtx)
4068
+ else {
4069
+ var n = 0
4070
+ var t = setInterval(function () {
4071
+ if (window.__ccvCtx) { clearInterval(t); boot(window.__ccvCtx) }
4072
+ else if (++n > 40) { clearInterval(t); log('v' + VERSION + ' 等不到 __ccvCtx,放弃自举') }
4073
+ }, 250)
4074
+ }
4075
+ })()