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.
package/lib/client.js CHANGED
@@ -11,7 +11,13 @@ window.__ModuleLoader__.load({
11
11
  try {
12
12
  if (!window.__ccvSideModulesBooted) {
13
13
  window.__ccvSideModulesBooted = true;
14
- ["/api/canvas/docdrop.js", "/api/canvas/doclink.js", "/api/canvas/selref.js"].forEach(function (u) {
14
+ // docdrop.js / doclink.js 随画布面板一起删了(2026-10-11)——它们是面板专属的:
15
+ // docdrop 把文件拖到**面板上**上传;doclink 把对话里的文档名链接指向**面板**。
16
+ // 留着两个没人调的模块只会白拉两次源码 ✗
17
+ // ⚠️ doc-model.js 必须排在 viewer-md.js **前面** ✓
18
+ // (A4:viewer-md 按块转换要用 window.__ccvDocModel ✓ 虽然是在渲染时才用,
19
+ // 但先加载它能让"模型没到 → 退回老路"这个兜底尽量不触发 ✓)
20
+ ["/api/canvas/ccv-logic.js", "/api/canvas/doc-model.js", "/api/canvas/selref.js", "/api/canvas/viewer-md.js"].forEach(function (u) {
15
21
  var name = u.slice(u.lastIndexOf("/") + 1);
16
22
  fetch(u + "?ts=" + Date.now()).then(function (r) {
17
23
  ccvLog(name + ": 源码拉取 HTTP " + r.status);
@@ -36,9 +42,28 @@ window.__ModuleLoader__.load({
36
42
  // ─── 会话标识:侧边栏「当前选中会话行」的标题 ───
37
43
  // 选中行有独立的 selected 类,切会话即变、同会话稳定——比窗口标题可靠
38
44
  //(窗口标题会随视图/加载状态变成无会话名的形态,导致键漂移)
45
+ //
46
+ // ★ 2026-10-26 重构 P1:纯逻辑搬进 lib/ccv-logic.js ★
47
+ // hashStr / 地址拼装 / 分组拼装 / 旧键反查 全部在那边 ✓ 单一实现 ✓ 可被 Node 测试 ✓
48
+ // 这里只保留**需要 DOM / window / ctx** 的部分 ✓(这些本来就测不了 ✓)
49
+ // ⚠️ 逻辑模块由侧模块引导异步加载 ✗ 所以用这个取值器:
50
+ // 真正用到它时(用户点开列表 / 打开文档)早就加载完了 ✓
51
+ // 取不到就**明确报错**并降级 ✓ 不静默 ✗
52
+ function ccvLogic() {
53
+ try {
54
+ var L = window.__ccvLogic
55
+ if (L && typeof L.buildGroups === 'function') return L
56
+ } catch (_) {}
57
+ ccvLog('逻辑层未加载 ✗(lib/ccv-logic.js 没到)—— 分组/地址功能会降级')
58
+ return null
59
+ }
60
+ // 兜底哈希:只在逻辑层没到时用 ✓(宁可算得对但重复,也不要直接崩 ✗)
39
61
  function hashStr(s) {
62
+ var L = ccvLogic()
63
+ if (L) return L.hashStr(s)
40
64
  let h = 5381;
41
- for (let i = 0; i < s.length; i++) { h = ((h << 5) + h + s.charCodeAt(i)) >>> 0; }
65
+ const str = String(s == null ? '' : s)
66
+ for (let i = 0; i < str.length; i++) { h = ((h << 5) + h + str.charCodeAt(i)) >>> 0; }
42
67
  return h.toString(36);
43
68
  }
44
69
  // 标题形如 "DeepSeek Harness - <会话名> — DeepSeek Harness",随会话切换变化;
@@ -54,11 +79,27 @@ window.__ModuleLoader__.load({
54
79
  } catch (_) {}
55
80
  return null;
56
81
  }
82
+ // ★ 2026-10-24:会话键优先用**真 sessionId** ★
83
+ // 主人原话:「现在要准确把分组名字显示成会话的名字」
84
+ // 老做法 = hash(会话名) ✗ 单向哈希 → 查不回会话名 → 分组只能显示乱码 ✓
85
+ // 真 sessionId 一直拿得到:
86
+ // · 查看器 props.sessionId → window.__ccvSessionId(publishInput 里写的 ✓)
87
+ // · 话布标签页 props.sessionId → 挂载时也写进同一个全局量 ✓
88
+ // → 有真 id 就用真 id ✓ 没有才退回老的哈希键(不能让功能炸 ✗)
89
+ function realSessionId() {
90
+ try {
91
+ const v = window.__ccvSessionId
92
+ if (v && typeof v === 'string' && /^session-/.test(v)) return v
93
+ } catch (_) {}
94
+ return ''
95
+ }
57
96
  function sessionKey() {
58
- const name = activeSessionTitle();
59
- if (name) return "sess-" + hashStr(name);
60
- const t = (document.title || "默认会话").trim() || "默认会话";
61
- return "t" + hashStr(t);
97
+ const real = realSessionId()
98
+ if (real) return real // ← 新行为 ✓
99
+ const name = activeSessionTitle() // ← 老兜底(哈希键 ✓)
100
+ if (name) return 'sess-' + hashStr(name)
101
+ const t = (document.title || '默认会话').trim() || '默认会话'
102
+ return 't' + hashStr(t)
62
103
  }
63
104
  function canvasApi(path) {
64
105
  try { return path + (path.indexOf("?") >= 0 ? "&" : "?") + "sid=" + encodeURIComponent(sessionKey()); } catch (_) { return path; }
@@ -72,11 +113,6 @@ window.__ModuleLoader__.load({
72
113
  }).then(function () {}, function () {});
73
114
  } catch (_) {}
74
115
  }
75
- // 只在高频路径上用的日志:CCV_DEBUG 关着时连字符串拼接都不做(msg 可传函数,按需求值)
76
- function ccvLogD(msg) {
77
- if (!CCV_DEBUG) return;
78
- try { ccvLog(typeof msg === "function" ? msg() : msg); } catch (_) {}
79
- }
80
116
  // ─── 子模块共用的小工具 ────────────────────────────────
81
117
  // docdrop.js / doclink.js 是「单文件、由 host 按请求供源码」的模块:改完刷新页面即生效、
82
118
  // 不用重启。代价是它们没法互相 require,只能复制代码 —— 日志、轻提示条、会话查询串
@@ -87,6 +123,37 @@ window.__ModuleLoader__.load({
87
123
  log: function (msg, tag) {
88
124
  ccvLog(tag ? "[" + tag + "] " + msg : msg);
89
125
  },
126
+ // markdown → HTML。给「接管查看器 .md」那个侧模块用(lib/viewer-md.js):
127
+ // 它要把文件内容渲染出来,而渲染器是本模块里的 mdToHtml —— 不重复造一份。
128
+ mdToHtml: function (md) { try { return mdToHtml(md); } catch (_) { return ""; } },
129
+ // React 也要借出去 —— 2026-10-04 补,这一条踩过坑:
130
+ // React 是本模块从**模块加载器** require("react") 拿的(见文件开头 L30),
131
+ // 而侧模块是 host 端点供源码后 `new Function(src)()` **裸 eval** 的 ——
132
+ // 它既没有 require,也没有 window.React。viewer-md.js 里写
133
+ // `window.React.createElement` 拿到的是 undefined → 组件 return null →
134
+ // 查看器里**一片空白**(而且看不出原因,因为组件明明被调用了)。
135
+ // 诊断日志打出 `React=没有` 才一眼定位。
136
+ React: React,
137
+ h: h,
138
+ // htmlToMd 也借出去 —— 2026-10-11 加。
139
+ // 为什么:往返自检(md → mdToHtml → htmlToMd → md2)本来挂在**面板**的加载流程里,
140
+ // 结果主人在**查看器**里翻文档,自检一次都没跑 ✗。
141
+ // 借给查看器之后,打开任何文档都能量到往返差异 —— 测量点跟着人走,而不是跟着代码走。
142
+ htmlToMd: function (el) { try { return htmlToMd(el); } catch (_) { return null; } },
143
+ // 建编辑区:**唯一实现**在主模块(见 ccvCreateScratch 的注释)——
144
+ // 查看器的划词按钮从这里取,保证两个入口行为一模一样。
145
+ createScratch: function (rec) { try { ccvCreateScratch(rec); } catch (_) {} },
146
+ // 草稿(未保存内容的本地兜底)—— **唯一实现在面板里** ✓
147
+ // 查看器拿它做"关页/崩溃不丢" ✓ 两个入口共用一份,行为必然一致 ✓
148
+ // (面板的做法见 draftSet / draftDropIf:打字停一下就存 localStorage ✓
149
+ // 打开文档时若草稿的基准版本对得上就恢复 ✓ 保存成功后丢掉 ✓)
150
+ draftGet: function (id) { try { return draftGet(id); } catch (_) { return null; } },
151
+ draftSet: function (id, content, base) { try { draftSet(id, content, base); } catch (_) {} },
152
+ draftDropIf: function (id, md) { try { draftDropIf(id, md); } catch (_) {} },
153
+ // 无条件丢掉草稿。用途:内容**回到基准**了(用户自己按 Ctrl+Z 撤回去了 ✓)
154
+ // 那份草稿就作废了 —— 不丢的话下次一查又会"恢复"回那次编辑 ✗✗
155
+ // (主人 2026-10-15 报的正是这个:改成斜体 → Ctrl+Z → 提示恢复了未保存的草稿 → 又变回斜体)
156
+ draftDrop: function (id) { try { draftDrop(id); } catch (_) {} },
90
157
  // 轻提示条。id 由调用方给:原来两处各用自己的 id、各自去重,这里保持同样口径。
91
158
  toast: function (msg, id) {
92
159
  try {
@@ -134,17 +201,6 @@ window.__ModuleLoader__.load({
134
201
  ccvLog("会话键: " + sessionKey() + " | 窗口标题: " + (document.title || ""));
135
202
  }
136
203
  } catch (_) {}
137
- function ccvDesc(el) {
138
- if (!el) return "null";
139
- return el.tagName + (el.id ? "#" + el.id : "") + (el.className && typeof el.className === "string" ? "." + el.className.split(/\s+/).slice(0, 2).join(".") : "");
140
- }
141
-
142
- // ─── 停靠模式(右侧栏)─────────────────────────────
143
- // 右侧栏的标签一切走,正文组件就被整个卸载重建(实测「正文已挂载」每次 +1)。
144
- // 也就是说:界面上的东西没有任何一样能指望它自己记得住。
145
- // 于是——① 面板实例放到模块级,方便别处复用;② 用户「刚敲进去、还没写盘」
146
- // 的内容在卸载前抢存一份到 localStorage,切回来时先恢复它。
147
- var CCV_DOCK_KIND = "collab-canvas";
148
204
  // 🔍 高频路径的调试日志开关。默认 false —— 因为"鼠标松开 / 划词判定 / 隐藏划词栏"
149
205
  // 这类动作一秒能触发几十次,每次都在拼调用栈并发一条网络请求写盘,属于实打实的卡顿源。
150
206
  // 平时静默;排查划词相关问题时把它改成 true,这些日志就会照旧出现。
@@ -161,11 +217,20 @@ window.__ModuleLoader__.load({
161
217
  // v4 2026-09-14:「过期草稿」提示改为只提示一次(加 notified 标记),并缩短文案
162
218
  // v5 2026-09-14:代码围栏的语言标记不再丢失(mdToHtml 存到 <pre data-lang>,
163
219
  // htmlToMd 读回);补了往返测试用例堵住这个盲区
164
- var CCV_CLIENT_VERSION = 5;
220
+ // v6 2026-09-28:接上撤销/重做(工具栏两个按钮 + Ctrl+Z/Y)。
221
+ // 撤销栈早在 host 侧就有(history/redoStack + canvas.undo RPC),
222
+ // 但客户端只走 HTTP、RPC 够不着,界面上等于不存在
223
+ // v7 2026-10-04:修「点了撤销没反应」两处 ——
224
+ // ① 程序性重绘后把「已同步」基线重定到 DOM 自己的序列化结果
225
+ // (md 往返不是无损的,拿服务端 md 当基线会误判成"用户改了字",
226
+ // 于是撤销前白写一步,撤掉的是刚写的同一份内容 = 看起来没反应);
227
+ // ② 撤销/重做按钮不再用 disabled 属性,栈空时也照样能点并给出提示
228
+ // v8 2026-10-04:撤销/重做**成功之后也给提示**。
229
+ // 原来只在失败时说话,成功时界面一片安静 —— 主人按过撤销自己也不确定,
230
+ // AI 那边更查不到,于是把"点了 3 次撤销"误判成了"面板覆盖文件"。
231
+ var CCV_CLIENT_VERSION = 8;
165
232
  var CCV_DRAFT_KEY = "ccv-draft-cache"; // { [canvasId]: { content, at, base } }
166
233
  var ccvCtx = null; // 模块级 ctx:tab 正文是独立组件,拿不到 apply 的闭包
167
- var ccvLiveEditor = null; // 当前编辑区 DOM。组件卸载后 React 会把 ref 置空,
168
- var ccvLiveCanvasId = null; // 但我们自己留的这份引用仍能读到用户刚敲的字。
169
234
  var ccvLiveVer = 0; // 本机已知的服务端版本号(组件内 versionRef 的镜像)
170
235
  function ccvDrafts() {
171
236
  try { var o = JSON.parse(localStorage.getItem(CCV_DRAFT_KEY) || "{}"); return o && typeof o === "object" ? o : {}; } catch (_) { return {}; }
@@ -188,16 +253,6 @@ window.__ModuleLoader__.load({
188
253
  // notified 每次写新草稿都归零:新内容可能又是一份会过期的草稿,该提示还得提示
189
254
  try { var all = ccvDrafts(); all[id] = { content: content, at: Date.now(), base: b, notified: false }; localStorage.setItem(CCV_DRAFT_KEY, JSON.stringify(all)); } catch (_) {}
190
255
  }
191
- // 标记「这份草稿过期的事已经提示过了」。
192
- // 没有它的话,草稿会一直躺在 localStorage 里,每次打开这篇话布都弹一次同样的提示 ——
193
- // 而用户看了也无事可做,只能无视(2026-09-14 手机上实测到这个问题)。
194
- function draftMarkNotified(id) {
195
- if (!id) return;
196
- try {
197
- var all = ccvDrafts();
198
- if (all[id]) { all[id].notified = true; localStorage.setItem(CCV_DRAFT_KEY, JSON.stringify(all)); }
199
- } catch (_) {}
200
- }
201
256
  function draftDrop(id) {
202
257
  if (!id) return;
203
258
  try { var all = ccvDrafts(); if (all[id]) { delete all[id]; localStorage.setItem(CCV_DRAFT_KEY, JSON.stringify(all)); } } catch (_) {}
@@ -210,12 +265,41 @@ window.__ModuleLoader__.load({
210
265
 
211
266
  // 窄屏(手机/平板竖屏):分栏拖拽没法用,话布一律全屏铺满。
212
267
  // 阈值 768 是常见断点;同时认 coarse pointer(触屏)兜底。
268
+ // ── 建「选区编辑区」(规格:canvas-docs/话布选区编辑区方案.md)──────────
269
+ // 点「问AI」时,除了照旧插一条引用,还把这一笔选区交给 host:
270
+ // 开一个临时画布(编辑区),AI 改它、**从不碰主文档**;这一轮结束时自动拼回去。
271
+ //
272
+ // 🔴 **两个入口必须共用这一份**(面板的划词栏 / 查看器的划词按钮)——
273
+ // 2026-10-11 主人报"我每次都是打开的老话布":那时只有查看器接了建区 ✗,
274
+ // 从面板点「问AI」压根不建区 → 注入没有、拼接也不会发生 ✗。
275
+ // 两条入口做的是同一件事,写两遍必然走偏(今晚 CSS 那次就是教训)。
276
+ // 所以这份是**唯一实现**,并挂在 __ccvKit 上给查看器用(见 lib/viewer-md.js)。
277
+ function ccvCreateScratch(rec) {
278
+ try {
279
+ if (!rec || !rec.code) return;
280
+ var path = "";
281
+ try {
282
+ // rec.doc.mention 是主模块按官方引号规则算好的 @"绝对路径"
283
+ path = String((rec.doc && rec.doc.mention) || "").replace(/^@"?/, "").replace(/"$/, "");
284
+ } catch (_) {}
285
+ fetch("/api/canvas/scratch", {
286
+ method: "POST",
287
+ headers: { "Content-Type": "application/json" },
288
+ body: JSON.stringify({
289
+ pinCode: rec.code,
290
+ docId: (rec.doc && rec.doc.id) || "",
291
+ docPath: path,
292
+ quote: rec.selected || "",
293
+ ordinal: rec.ordinal || 0,
294
+ dupCount: rec.dupCount || 0,
295
+ }),
296
+ }).then(function (r) { return r.json(); })
297
+ .then(function (d) { ccvLog("编辑区:" + JSON.stringify(d)); })
298
+ .catch(function (e) { ccvLog("编辑区建立失败:" + (e && e.message)); });
299
+ } catch (e) { ccvLog("编辑区建立异常:" + (e && e.message)); }
300
+ }
301
+
213
302
  var NARROW_MAX = 768;
214
- function isNarrow() {
215
- if (window.matchMedia && window.matchMedia('(pointer: coarse)').matches &&
216
- window.innerWidth < 1024) return true;
217
- return window.innerWidth < NARROW_MAX;
218
- }
219
303
 
220
304
  // ─── Markdown ↔ HTML 转换(纯函数)────────────────────
221
305
  // 2026-09-12 从 CanvasPanel 里搬到这里。理由:这两个只依赖传进来的 DOM / 字符串,
@@ -352,11 +436,64 @@ window.__ModuleLoader__.load({
352
436
  tblOut.push("| " + tblRows[trJ].join(" | ") + " |");
353
437
  if (trJ === 0) {
354
438
  var sepArr = [];
355
- for (var scI = 0; scI < tblW; scI++) sepArr.push("---");
439
+ for (var scI = 0; scI < tblW; scI++) {
440
+ // 对齐(2026-10-15 加):从**第一行**对应单元格的 inline text-align 读 ✓
441
+ // (单元格里的块级元素也可能带 text-align —— execCommand('justifyCenter')
442
+ // 就是写在那上面的 ✗ 所以两处都看 ✓)
443
+ // 左 / 默认 → 写裸 --- ✓(左是 markdown 的默认 ✓ 文件更干净 ✓)
444
+ var al = "";
445
+ try {
446
+ var c0 = tblTrs[0].querySelectorAll("th,td")[scI];
447
+ var stA = (c0 && c0.getAttribute("style")) || "";
448
+ var amA = stA.match(/text-align:\s*(left|center|right)/);
449
+ if (!amA && c0 && c0.firstElementChild) {
450
+ var stB = c0.firstElementChild.getAttribute("style") || "";
451
+ var amB = stB.match(/text-align:\s*(left|center|right)/);
452
+ if (amB) amA = amB;
453
+ }
454
+ if (amA) al = amA[1];
455
+ } catch (_) {}
456
+ sepArr.push(al === "center" ? ":---:" : (al === "right" ? "---:" : "---"));
457
+ }
356
458
  tblOut.push("| " + sepArr.join(" | ") + " |");
357
459
  }
358
460
  }
359
- return "\n" + tblOut.join("\n") + "\n";
461
+ var tblMd = "\n" + tblOut.join("\n") + "\n";
462
+ // ── 列宽(2026-10-15 加)──────────────────────────────────
463
+ // 拖动列边界之后 DOM 里会有 <colgroup><col style="width:40%"> ✓
464
+ // 把它序列化成紧随表格的一行注释:<!-- ccv-table: 40,60 -->
465
+ // mdToHtml 读它并把 <colgroup> 放回去 ✓(往返保真 ✓)
466
+ // 全是 auto(没有 colgroup / 宽度都是 auto)就不写注释 ✓ 保持文件干净 ✓
467
+ try {
468
+ var colEls = n.querySelectorAll ? n.querySelectorAll("colgroup col") : [];
469
+ var colWs = [];
470
+ for (var ci2 = 0; ci2 < colEls.length; ci2++) {
471
+ var st2 = colEls[ci2].getAttribute("style") || "";
472
+ var mw = st2.match(/width:\s*([\d.]+)%/);
473
+ colWs.push(mw ? Math.round(parseFloat(mw[1])) : 0);
474
+ }
475
+ var hasW = false;
476
+ for (var wi = 0; wi < colWs.length; wi++) { if (colWs[wi] > 0) { hasW = true; break; } }
477
+ if (hasW) tblMd += "<!-- ccv-table: " + colWs.join(",") + " -->\n";
478
+ } catch (_) {}
479
+ // ── 行高(2026-10-15 加)──────────────────────────────────
480
+ // 拖过行边界之后 <tr> 上会有 style="height:32px" ✓
481
+ // 序列化成另一行注释:<!-- ccv-table-h: 32,auto -->
482
+ // auto = 没拖过(由内容撑开 ✓)→ 一个都没拖过就不写注释 ✓ 保持文件干净 ✓
483
+ try {
484
+ var trEls = n.querySelectorAll ? n.querySelectorAll("tr") : [];
485
+ var rowHs = [];
486
+ var hasH = false;
487
+ for (var ri2 = 0; ri2 < trEls.length; ri2++) {
488
+ var st3 = trEls[ri2].getAttribute("style") || "";
489
+ var mh = st3.match(/height:\s*(\d+(?:\.\d+)?)px/);
490
+ var hv = mh ? Math.round(parseFloat(mh[1])) : 0;
491
+ if (hv > 0) hasH = true;
492
+ rowHs.push(hv > 0 ? hv : "auto");
493
+ }
494
+ if (hasH) tblMd += "<!-- ccv-table-h: " + rowHs.join(",") + " -->\n";
495
+ } catch (_) {}
496
+ return tblMd;
360
497
  }
361
498
  case "a":
362
499
  var href = n.getAttribute && n.getAttribute("href") || "";
@@ -569,7 +706,64 @@ window.__ModuleLoader__.load({
569
706
  // 若在这里 i+=2 跳过分隔行,第一行正文会被 buildTable 当分隔行吃掉
570
707
  i += 1;
571
708
  while (i < lines.length && lines[i].trim() && lines[i].indexOf("|") >= 0) { tblLines.push(lines[i].trim()); i++; }
572
- out.push(buildTable(tblLines));
709
+ var tblHtml = buildTable(tblLines);
710
+ // ── 列宽(2026-10-15 加)────────────────────────────────
711
+ // 拖过列边界的表格,紧跟一行注释:<!-- ccv-table: 40,60 -->
712
+ // 用注释而不是内嵌 HTML 表格:**表格保持 markdown 的可读形式** ✓
713
+ // 注释在渲染时不可见 ✓ 也不会被当成正文 ✓
714
+ // (markdown 表格语法本身没有宽高 ✗ —— 成熟 markdown 工具也是靠外挂信息 ✓)
715
+ var wm = (lines[i] || "").trim().match(/^<!--\s*ccv-table:\s*([\d.,\s]+?)\s*-->$/);
716
+ if (wm) {
717
+ var ws = wm[1].split(",").map(function (x) { return parseFloat(x) || 0; });
718
+ if (ws.length) {
719
+ var cg = "<colgroup>" + ws.map(function (x) {
720
+ return x > 0 ? '<col style="width:' + x + '%">' : "<col>";
721
+ }).join("") + "</colgroup>";
722
+ tblHtml = tblHtml.replace("<table>", "<table>" + cg);
723
+ i++; // 吃掉注释行 ✓(别让它渲染出来)
724
+ }
725
+ }
726
+ // ── 行高(2026-10-15 加)────────────────────────────────
727
+ // 同一套思路,另一个标记:<!-- ccv-table-h: 32,auto -->
728
+ // auto = 由内容撑开(成熟产品的默认行为 ✓)→ 不写 style ✓
729
+ var hm = (lines[i] || "").trim().match(/^<!--\s*ccv-table-h:\s*([\d.,auto\s]+?)\s*-->$/);
730
+ if (hm) {
731
+ var hs = hm[1].split(",").map(function (x) {
732
+ var v = String(x).trim();
733
+ return (v === "auto" || v === "" || !(parseFloat(v) > 0)) ? 0 : Math.round(parseFloat(v));
734
+ });
735
+ if (hs.length) {
736
+ var trIdx = -1;
737
+ tblHtml = tblHtml.replace(/<tr>/g, function () {
738
+ trIdx++;
739
+ var h = hs[trIdx] || 0;
740
+ return h > 0 ? '<tr style="height:' + h + 'px">' : "<tr>";
741
+ });
742
+ i++; // 吃掉注释行 ✓
743
+ }
744
+ }
745
+ // ── 单元格对齐(2026-10-15 加)──────────────────────────
746
+ // markdown 的**分隔行自带对齐语法** ✓ —— 这是标准 markdown ✓ 不用外挂注释 ✓
747
+ // | :--- | :---: | ---: | → 左 / 中 / 右
748
+ // (只有左是默认,所以左可以不写冒号;中间/右边必须写 ✓)
749
+ var sepLine = String(tblLines[1] || "").trim().replace(/^\|/, "").replace(/\|$/, "");
750
+ var aligns = sepLine.split("|").map(function (cc) {
751
+ var s2 = cc.trim();
752
+ var lf = s2.charAt(0) === ":";
753
+ var rt = s2.charAt(s2.length - 1) === ":";
754
+ return (lf && rt) ? "center" : (rt ? "right" : (lf ? "left" : ""));
755
+ });
756
+ var anyAlign = false;
757
+ for (var ai3 = 0; ai3 < aligns.length; ai3++) { if (aligns[ai3]) { anyAlign = true; break; } }
758
+ if (anyAlign) {
759
+ var cellIdx = -1;
760
+ tblHtml = tblHtml.replace(/<(th|td)>/g, function (m0, tag) {
761
+ cellIdx++;
762
+ var a0 = aligns[cellIdx % Math.max(1, aligns.length)];
763
+ return a0 ? "<" + tag + ' style="text-align:' + a0 + '">' : m0;
764
+ });
765
+ }
766
+ out.push(tblHtml);
573
767
  continue;
574
768
  }
575
769
  }
@@ -676,1576 +870,793 @@ window.__ModuleLoader__.load({
676
870
  }
677
871
 
678
872
  // ─── 静态资源(模块级)─────────────────────────────
679
- // 2026-09-12 从 CanvasPanel 里搬出来。这些要么是纯常量、要么只依赖彼此,
680
- // 与任何组件状态无关 —— 留在组件体内等于每渲染一次就重造一遍(划词栏一弹出就是几十次)。
681
-
682
- // ─── 黑白线条图标库(stroke=currentColor,随主题/悬停变色,避免 emoji 彩色不统一)──
683
- var CCV_ICONS = {
684
- ask: '<path d="M4 5h16v11H10l-6 4z"/>',
685
- 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"/>',
686
- clear: '<path d="M16 4l4 4-9 9H7l-3-3L16 4z"/><path d="M5 21h14"/><path d="M11 9l4 4"/>',
687
- uploadImage: '<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M12 16v-5M9.5 13.5L12 11l2.5 2.5"/>',
688
- 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"/>',
689
- 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"/>',
690
- table: '<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M3 9.5h18M9.5 9.5V20M15.5 9.5V20"/>',
691
- task: '<rect x="4" y="4" width="16" height="16" rx="3"/><path d="M8.5 12.5l2.5 2.5 5-5.5"/>',
692
- 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"/>',
693
- 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>',
694
- 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"/>',
695
- code: '<path d="M8.5 7.5L4 12l4.5 4.5M15.5 7.5L20 12l-4.5 4.5"/>',
696
- 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"/>',
697
- 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"/>',
698
- hr: '<path d="M4 12h16"/>',
699
- hlBlock: '<rect x="3" y="5" width="18" height="14" rx="3"/><path d="M7 12h10" stroke-width="2.4"/>',
700
- indent: '<path d="M4 5h16M10 10h10M10 14h10M4 19h16M7 9.5L4.5 12 7 14.5"/>',
701
- outdent: '<path d="M4 5h16M10 10h10M10 14h10M4 19h16M4.5 9.5L7 12l-2.5 2.5"/>',
702
- }
703
- function ccvIcon(name, size) {
704
- var body = CCV_ICONS[name]
705
- if (!body) return ""
706
- var s = size || 15
707
- return '<svg viewBox="0 0 24 24" width="' + s + '" height="' + s + '" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" style="display:block">' + body + '</svg>'
708
- }
709
-
710
- var SELBAR_ITEMS = [
711
- { label: ccvIcon("ask") + '<span style="margin-left:5px">问AI</span>', title: "将选中文本发送给 AI 问答", isAskAI: true },
712
- { type: "sep" },
713
- { label: "H", cmd: "formatBlock", val: "<h2>", title: "标题", isHeading: true, hasDropdown: true },
714
- { type: "sep" },
715
- { label: "≡", title: "对齐", hasDropdown: true, showAlign: true },
716
- { type: "sep" },
717
- { label: "<b>B</b>", cmd: "bold", title: "粗体" },
718
- { label: "<i>I</i>", cmd: "italic", title: "斜体" },
719
- { label: "<s>S</s>", cmd: "strikeThrough", title: "删除线" },
720
- { label: "U", cmd: "underline", title: "下划线", style: "text-decoration:underline" },
721
- { label: ccvIcon("highlight"), cmd: "highlight", title: "高亮" },
722
- { label: ccvIcon("clear"), cmd: "clearFormat", title: "清除格式" },
723
- { type: "sep" },
724
- { label: "⋮", title: "更多功能", hasDropdown: true, showFullMenu: true },
725
- ]
726
-
727
- // ─── 样式常量 ──────────────────────────────────
728
- var CCV_STYLE = {
729
- // 定位/宽高/边框由注入的 CSS(!important)统一控制,这里只放外观
730
- // 内层根 div 必须填满 #collab-canvas-panel 宿主(flex 列、高 100vh),
731
- // 否则它没有确定高度 → 编辑区 flex:1 1 0% 解析成 0 → 内容被压没、overflowY 失效、滚轮滚不动。
732
- // minWidth/minHeight 必须写 0:flex 子项默认 min-width:auto,会被内容(宽表格、
733
- // 长代码块)顶着涨,撑出容器 —— 停靠在窄侧栏里尤其明显。
734
- panel: { flex: "1 1 0%", minHeight: 0, minWidth: 0, background: "var(--dsw-alias-bg-layer-1, #fff)", color: "var(--dsw-alias-label-primary, #1a1a1a)", display: "flex", flexDirection: "column", fontFamily: "var(--font-ui, system-ui, sans-serif)" },
735
- // overflowX:auto —— 宽表格/长代码块在窄侧栏里自己横向滚动,而不是把面板撑宽
736
- editorWide: { flex: "1 1 0%", minHeight: 0, minWidth: 0, overflowY: "auto", overflowX: "auto", padding: "24px 32px", outline: "none", fontSize: 14, lineHeight: 1.75, wordBreak: "break-word" },
737
- // 窄屏/窄侧栏:内边距收紧,省下的宽度留给内容
738
- editorCompact: { flex: "1 1 0%", minHeight: 0, minWidth: 0, overflowY: "auto", overflowX: "auto", padding: "14px 16px", outline: "none", fontSize: 14, lineHeight: 1.75, wordBreak: "break-word" }
739
- };
740
-
741
- // ─── 编辑器面板组件 ─────────────────────────────────
742
- // 只以「右侧栏标签正文」这一种方式被挂载(唯一调用点:下面的 CanvasDockBody)。
743
- // 自建悬浮窗口 —— 连同顶栏按钮、拖拽边框、自带全屏、宽度测量、挤压对话列的整套
744
- // 布局引擎 —— 已于 2026-09-12 整体删除;官方浮动(把标签拖出面板)走的也是这条路。
745
- // 现在面板宽度完全由右侧栏标签页决定,自己量,不再碰对话列。
746
- function CanvasPanel({ ctx }) {
747
- const [canvases, setCanvases] = useState([]);
748
- const [activeId, setActiveId] = useState(null);
749
- // 顶栏两行:creating = 行内新建输入框展开(不弹浮层,直接插在第一行流里)
750
- // dropOpen = 文件名胶囊下的话布切换下拉(absolute,挂在第一行内)
751
- const [creating, setCreating] = useState(false);
752
- const [newTitle, setNewTitle] = useState("");
753
- const [dropOpen, setDropOpen] = useState(false);
754
- const [content, setContent] = useState("");
755
- const [version, setVersion] = useState(0);
756
- const [narrow, setNarrow] = useState(isNarrow());
757
- // 面板宽度由右侧栏标签页给,自己量(拖宽拖窄标签页时跟着变)
758
- const [selfW, setSelfW] = useState(0);
759
- const [restored, setRestored] = useState(false); // 顶栏显示「已恢复未保存内容」
760
- const editorRef = useRef(null);
761
- const saveTimer = useRef(null);
762
- const panelRef = useRef(null);
763
- const meterRef = useRef(null);
764
- // 兜底宽度用 ref 存一份。下面几个测量 effect 只在挂载时重跑一次,
765
- // 若把宽度写进依赖数组,就会绕成「量宽度 → setSelfW → 宽度变 → 又去量」的圈。
766
- // 用 ref 读最新值:既拿不到过期的数(原来的隐患),也不会多跑。
767
- const panelWRef = useRef(selfW);
768
- panelWRef.current = selfW;
769
- const draftTimer = useRef(null);
770
- // 划词选区暂存。2026-09-12 从组件函数体的普通变量改成 ref,理由见 saveSelection 处。
771
- const savedRangeRef = useRef(null);
772
- // ── 并发保护(2026-09-12)────────────────────────────────
773
- // version 是 useState,但自动保存跑在 setTimeout 回调里,闭包抓到的是「设定时器
774
- // 那次渲染」的值,可能已经过期。所以另存一份 ref,写入时读它才是当前已知版本号。
775
- const versionRef = useRef(0);
776
- // 最后一次「与服务端一致」的内容。用于冲突时判断本机有没有未保存的编辑:
777
- // DOM 内容 === lastSyncedMd.current → 用户没动过,可以安全采用服务端版本
778
- // 不等 → 用户正在编辑,绝不能拿服务端内容覆盖他
779
- const lastSyncedMd = useRef(null);
780
- // "窄"由面板自己的宽度决定,而不是整个窗口 ——
781
- // 窗口 1600 宽、侧栏只给 315 时,面板也是窄的,得用紧凑内边距。
782
- const isNarrowPane = selfW > 0 && selfW < 520;
783
- const compact = narrow || isNarrowPane;
784
-
785
- // 横竖屏切换 / 窗口缩放时刷新窄屏判定
786
- useEffect(() => {
787
- function onResize() { setNarrow(isNarrow()); }
788
- window.addEventListener('resize', onResize);
789
- window.addEventListener('orientationchange', onResize);
790
- return function () {
791
- window.removeEventListener('resize', onResize);
792
- window.removeEventListener('orientationchange', onResize);
793
- };
794
- }, []);
873
+ // (画布面板 2026-10-11 整体删除后,这里只剩下面这些共享件:
874
+ // markdown 转换、草稿、__ccvKit、侧模块加载。图标库 / 划词栏条目 /
875
+ // 面板布局常量都随面板一起删了 —— 那些是面板专属的 ✗)
876
+
877
+
878
+
879
+
880
+
881
+ // 面板专属的会话服务声明(slots 之外的那些)
882
+ //
883
+ // 🔴 每一项都要写清**谁在用** —— 2026-10-14 我"清理未用声明"时删掉了 conversation ✗,
884
+ // 而它是 **lib/selref.js 要的**:那个文件里 missingServices() 要
885
+ // sessions / conversation / inputTriggers **三个齐了**才注册引用源 ✗
886
+ // 结果:引用源没注册 → 聊天里报
887
+ // 「slash: no serializer for reference source "话布选区"」✗
888
+ // 依赖在**另一个文件**里,从这个文件看不出来 ✗ —— 所以逐条注明 ✓
889
+ // verify-all 里也加了守卫(那三项缺一不可 ✓)
890
+ //
891
+ // slots → 主模块:把查看器正文/工具栏注册进右侧栏(__ccvViewerBoot)
892
+ // sessions → lib/selref.js:由会话 id 取作用域、拿输入框句柄
893
+ // conversation → lib/selref.js:同上(missingServices 三个之一 ✗ 别删)
894
+ // inputTriggers → lib/selref.js:注册「话布选区」引用源
895
+ // sidebarRightTabs → 主模块:声明右侧栏「话布」标签类型(两阶段注册之一)
896
+ // sidebarRight → **2026-10-22 起有调用点了** ✓
897
+ // 用它打开文档:ctx.sidebarRight.openResource(地址) ✓
898
+ // 契约来自官方 dsh-client-ui-sidebar-right 的 service.d.ts:
899
+ // openResource(address: string, options?): void
900
+ // address 必须是 `dsh-resource://<type>/…` ✓
901
+ // 不接受的话**会抛**(官方注释:那是接线错误,不是用户错误 ✓)
902
+ // 这就是主人要的"点一下跳到文档"✓
903
+ // ⚠️ **必须写进这里,不能运行时 ctx.get 现取** —— 未声明的服务取不到。
904
+ // 2026-09-14 实测:不加 sessions / inputTriggers 时 selref 自检报「暂缺」
905
+ const inject = ["slots", "sessions", "conversation", "inputTriggers", "sidebarRightTabs", "sidebarRight"];
795
906
 
796
- // 读数直接写 DOM,不走 state。停靠模式只有一种读数:面板自己被侧栏分到多宽。
797
- function updateMeter(w) {
798
- if (!meterRef.current) return;
799
- meterRef.current.textContent = Math.round(w) + "px · 侧栏";
800
- }
801
- // 挂载后(CSS 已生效)测一次真实宽度。必须现场量 panelRef ——
802
- // 不能用上面那个渲染时就定住的 selfW,首帧它还是 0,会把读数写成 0px。
803
- useEffect(() => {
804
- var t = setTimeout(function () {
805
- var el = panelRef.current;
806
- var w = el ? el.getBoundingClientRect().width : panelWRef.current;
807
- setSelfW(Math.round(w));
808
- updateMeter(w);
809
- }, 0);
810
- return function () { clearTimeout(t); };
811
- }, []);
812
-
813
- // 宽度由右侧栏标签页给,自己量。拖宽拖窄标签页时跟着变。
814
- useEffect(function () {
815
- var el = panelRef.current;
816
- if (!el) return;
817
- var measure = function () {
818
- var w = Math.round(el.getBoundingClientRect().width);
819
- setSelfW(w);
820
- updateMeter(w);
821
- };
822
- measure();
823
- if (typeof ResizeObserver === "undefined") return;
824
- var ro = new ResizeObserver(measure);
825
- ro.observe(el);
826
- return function () { ro.disconnect(); };
827
- }, []);
828
-
829
- // 把「正在编辑的 DOM + 它属于哪个话布」记到模块级:卸载后 React 会把 ref 置空,
830
- // 我们留的这份引用还能读到用户刚敲进去的字,用来抢存草稿。
831
- useEffect(function () { ccvLiveCanvasId = activeId; }, [activeId]);
832
- // 把组件内的已知版本号镜像到模块级:draftSet 是模块级函数,读不到 versionRef,
833
- // 但草稿必须记住自己的基准版本(见 draftSet 注释)。每次渲染后同步一次即可 ——
834
- // versionRef 的每次变更都伴随 setVersion → 必然触发渲染。
835
- useEffect(function () { ccvLiveVer = versionRef.current; });
836
- useEffect(function () {
837
- ccvLiveEditor = editorRef.current;
838
- return function () {
839
- // 卸载瞬间抢存:右侧栏标签一切走,本组件就被整个拆掉,晚一步就读不到内容了
840
- try {
841
- if (ccvLiveCanvasId && ccvLiveEditor) draftSet(ccvLiveCanvasId, htmlToMd(ccvLiveEditor));
842
- } catch (e) { ccvLog("草稿抢存失败: " + (e && e.message)); }
843
- ccvLiveEditor = null;
844
- };
845
- }, []);
846
-
847
- // ── 选区引用(侧模块 lib/selref.js)的取数桥 ──────────────────────
848
- // 侧模块由 new Function 执行,跑在**全局作用域**,看不到这里的
849
- // canvases / activeId / editorRef / versionRef。所以只把三个「现读现取」的
850
- // 取值器挂出去,**怎么算位置、怎么翻译全部留在侧模块里** ——
851
- // 主文件这边保持极薄:这里只挂桥,不搬逻辑(理由见设计稿第十节)。
852
- useEffect(function () {
853
- window.__ccvSelRefBridge = {
854
- doc: function () {
855
- var c = null
856
- for (var i = 0; i < canvases.length; i++) {
857
- if (canvases[i] && canvases[i].id === activeId) { c = canvases[i]; break }
858
- }
859
- return {
860
- id: activeId,
861
- title: c ? c.title : "",
862
- // mention 直接复用现成的 @ 路径写法(含引号规则、旧 host 的兜底),不另写一份
863
- mention: ccvDocMention(activeId),
864
- version: versionRef.current
865
- }
866
- },
867
- editor: function () { return editorRef.current },
868
- // 现选区优先;手指已经离开编辑区(选区塌了)就退回暂存的那份
869
- range: function () { return currentRange() || savedRangeRef.current },
870
- // ── 下面两个是「AI 改完跳过去给你看」用的(需求见设计稿第十五节)──
871
- // 打开指定文档。AI 的改动可能落在**别的**文档里(主人在看 A,它改了 B),
872
- // 那就得先把 B 打开。复用现成的 loadCanvas:它是**异步**的(要拉内容),
873
- // 所以返回 true 只表示「正在切」,调用方必须等渲染完再去找位置。
874
- openDoc: function (id) {
875
- if (!id || id === activeId) return false; // 已经开着就不用切
876
- var hit = false;
877
- for (var i = 0; i < canvases.length; i++) {
878
- if (canvases[i] && canvases[i].id === id) { hit = true; break; }
879
- }
880
- if (!hit) return false; // 文档不在了(可能被删了)
881
- loadCanvas(id);
882
- return true;
883
- },
884
- // 主人是不是正在画布编辑区里打字。是的话**本轮不跳** ——
885
- // 跳转会挪走滚动位置、还会抢掉他的选区,正在输入的人会直接骂。
886
- // 宁可慢一拍(下次轮询再跳),也不能打断输入。
887
- isEditing: function () {
888
- var ed = editorRef.current;
889
- if (!ed) return false;
890
- var ae = document.activeElement;
891
- return !!(ae && (ae === ed || (ed.contains && ed.contains(ae))));
892
- }
893
- }
894
- }, [canvases, activeId]);
895
-
896
- // 防抖落草稿(打字时只更新内存引用,500ms 空闲才动 localStorage)
897
- function scheduleDraft() {
898
- var el = editorRef.current;
899
- if (!el || !activeId) return;
900
- ccvLiveEditor = el;
901
- ccvLiveCanvasId = activeId;
902
- if (draftTimer.current) return;
903
- draftTimer.current = setTimeout(function () {
904
- draftTimer.current = null;
905
- try {
906
- if (ccvLiveEditor && ccvLiveCanvasId) draftSet(ccvLiveCanvasId, htmlToMd(ccvLiveEditor));
907
- } catch (_) {}
908
- }, 500);
909
- }
910
-
911
- // 关页面/刷新前也抢一次:F5 不会触发 React 卸载
912
- useEffect(function () {
913
- function flush() { try { if (ccvLiveCanvasId && ccvLiveEditor) draftSet(ccvLiveCanvasId, htmlToMd(ccvLiveEditor)); } catch (_) {} }
914
- window.addEventListener("beforeunload", flush);
915
- window.addEventListener("pagehide", flush);
916
- return function () {
917
- window.removeEventListener("beforeunload", flush);
918
- window.removeEventListener("pagehide", flush);
919
- };
920
- }, []);
921
-
922
- // 全局点击隐藏划词栏和下拉菜单
923
- useEffect(() => {
924
- function onGlobalMouseUp(e) {
925
- var bar = document.getElementById('ccv-selbar')
926
- if (bar && bar.style.display !== 'none') {
927
- if (bar.contains(e.target)) return
928
- if (editorRef.current && editorRef.current.contains(e.target)) return
929
- ccvLog('globalMouseUp: 命中隐藏分支 target=' + ccvDesc(e.target) +
930
- ' editorRef=' + (editorRef.current ? '有' : '空') +
931
- ' 在编辑区内=' + !!(editorRef.current && editorRef.current.contains(e.target)))
932
- hideSelbar()
933
- }
934
- // 下拉菜单由各自的 mousedown 事件处理关闭
935
- }
936
- document.addEventListener('mouseup', onGlobalMouseUp)
937
- return function () { document.removeEventListener('mouseup', onGlobalMouseUp) }
938
- }, [])
939
-
940
- // 面板挂载后自检:栏元素在不在、编辑区 ref 有没有、面板祖先链状态
941
- useEffect(function () {
942
- var t = setTimeout(function () {
943
- var bar = document.getElementById('ccv-selbar')
944
- var panel = panelRef.current
945
- // 版本标记:client.js 由 DSH 打包加载,浏览器缓存/未刷新时从外观上看不出来。
946
- // 排查「改动没生效」时第一件事就是核对它(doclink 早就因此加了 VERSION)。
947
- ccvLog('client v' + CCV_CLIENT_VERSION + ' 已挂载')
948
- var host = panel ? (panel.closest('#collab-canvas-panel') || panel.parentElement) : null
949
- var hostCs = host ? getComputedStyle(host) : null
950
- ccvLog('挂载自检: panel=' + ccvDesc(panel) + ' host=' + ccvDesc(host) +
951
- ' hostOverflow=' + (hostCs ? hostCs.overflow : '?') +
952
- ' hostTransform=' + (hostCs ? hostCs.transform : '?') +
953
- ' editorRef=' + (editorRef.current ? '有' : '空'))
954
- ccvLog('挂载自检: #ccv-selbar=' + (bar ? ('存在 display=' + bar.style.display + ' offsetParent=' + ccvDesc(bar.offsetParent)) : '缺失'))
955
- }, 600)
956
- return function () { clearTimeout(t) }
957
- }, [])
958
-
959
- // contentEditable 里的 <a> 浏览器默认不跳转(单击只把光标移进去):
960
- // - 脚注引用 [1]:capture 拦截,手动 scrollIntoView 跳到文末
961
- // - 普通链接:单击直接新标签页打开,两端行为一致(外链开网页,
962
- // attachments/ 附件链接触发下载)。要改链接文字/地址:选中新链接文字后
963
- // 重新「插入链接」即可覆盖
964
- useEffect(function () {
965
- var ed = editorRef.current;
966
- if (!ed) return;
967
- function onClick(e) {
968
- var a = e.target.closest && e.target.closest('a[href]');
969
- if (!a) return;
970
- var href = a.getAttribute('href') || '';
971
- if (!href) return;
972
- if (href.indexOf('#fn') === 0) {
973
- e.preventDefault();
974
- e.stopPropagation();
975
- var id = href.slice(1);
976
- var target = document.getElementById(id) || ed.querySelector('[id="' + id + '"]');
977
- if (target) target.scrollIntoView({ behavior: 'smooth', block: 'center' });
978
- return;
979
- }
980
- // 单击直接新标签页打开(外链开网页,attachments/ 附件链接触发下载)。
981
- // 两端行为一致:手机端本来就没有 Ctrl 键,桌面端跟随。
982
- // 只放行 http(s) 和站内路径(附件接口),防 javascript: 之类的注入
983
- var openable = /^https?:\/\//i.test(href) || href.charAt(0) === '/';
984
- ccvLog('链接点击: href=' + href + ' openable=' + openable);
985
- if (!openable) return;
986
- e.preventDefault();
987
- e.stopPropagation();
988
- window.open(href, '_blank', 'noopener');
989
- }
990
- ed.addEventListener('click', onClick, true);
991
- return function () { ed.removeEventListener('click', onClick, true); };
992
- }, []);
993
-
994
- // 编辑区增强:粘贴截图自动上传、Tab/Shift+Tab 列表缩进
995
- useEffect(function () {
996
- var ed = editorRef.current;
997
- if (!ed) return;
998
- function inListItem() {
999
- var sel = document.getSelection()
1000
- if (!sel || sel.rangeCount === 0) return false
1001
- var node = sel.getRangeAt(0).startContainer
1002
- if (node.nodeType === 3) node = node.parentNode
1003
- while (node && node.nodeType === 1) {
1004
- var tag = node.tagName ? node.tagName.toLowerCase() : ""
1005
- if (tag === "li" || tag === "ul" || tag === "ol") return true
1006
- if (node.classList && node.classList.contains("ccv-editor")) break
1007
- node = node.parentNode
1008
- }
1009
- return false
1010
- }
1011
- function onKeyDown(e) {
1012
- if (e.key !== "Tab") return
1013
- e.preventDefault()
1014
- if (inListItem()) {
1015
- // 列表里:Tab 嵌套一级、Shift+Tab 退回一级,效果相反
1016
- document.execCommand(e.shiftKey ? "outdent" : "indent", false, null)
1017
- } else if (!e.shiftKey) {
1018
- // 非列表:Tab 插两个空格;Shift+Tab 无处可退,不做动作
1019
- // (之前 Shift+Tab 也走这里插空格,两种按起来一样,是 bug)
1020
- document.execCommand("insertText", false, " ")
1021
- }
1022
- }
1023
- function onPaste(e) {
1024
- var items = e.clipboardData && e.clipboardData.items
1025
- if (!items) return
1026
- for (var i = 0; i < items.length; i++) {
1027
- var it = items[i]
1028
- if (it.kind === "file" && it.type && it.type.indexOf("image/") === 0) {
1029
- var f = it.getAsFile()
1030
- if (!f) continue
1031
- e.preventDefault()
1032
- // 剪贴板里的截图通常叫 image.png,重命名带上时间戳便于辨认
1033
- var ext = (it.type.split("/")[1] || "png").replace(/[^a-z0-9]/gi, "") || "png"
1034
- var name = "clipboard-" + Date.now() + "." + ext
1035
- try { f = new File([f], name, { type: it.type }) } catch (_) { /* 旧环境保留原名 */ }
1036
- var rng = currentRange()
1037
- uploadCanvasFile(f, function(p) {
1038
- insertHtmlAtRange('<img src="' + escHtml(assetDisplayUrl(p)) + '" alt="截图" style="max-width:100%;height:auto;vertical-align:middle"/>', rng)
1039
- }, function(f2) {
1040
- // 上传失败兜底:dataURL 内嵌
1041
- fileToDataUrl(f2, function(u) {
1042
- if (u) insertHtmlAtRange('<img src="' + u + '" alt="截图" style="max-width:100%;height:auto;vertical-align:middle"/>', rng)
1043
- else ccvLog("paste: 图片上传失败且无兜底")
1044
- })
1045
- })
1046
- return
1047
- }
1048
- }
1049
- }
1050
- ed.addEventListener("keydown", onKeyDown)
1051
- ed.addEventListener("paste", onPaste)
1052
- return function () {
1053
- ed.removeEventListener("keydown", onKeyDown)
1054
- ed.removeEventListener("paste", onPaste)
1055
- };
1056
- }, []);
1057
-
1058
- // 兜底触发:编辑区的 React onMouseUp 不是每次都生效(拖选结束时指针落在
1059
- // 编辑区外、选区在 mouseup 之后才定型等)。用 selectionchange 补一次,
1060
- // 只在「选区确实落在编辑区内」时才接管,避免污染会话区的划词。
1061
- useEffect(function () {
1062
- var t = null
1063
- function onSelChange() {
1064
- if (t) clearTimeout(t)
1065
- t = setTimeout(function () {
1066
- try {
1067
- var sel = document.getSelection()
1068
- if (!sel || sel.rangeCount === 0 || sel.isCollapsed) return
1069
- var ed = editorRef.current
1070
- if (!ed || !sel.anchorNode || !ed.contains(sel.anchorNode)) return
1071
- var bar = document.getElementById('ccv-selbar')
1072
- if (bar && bar.style.display !== 'none') return // 已经显示就不再重复渲染
1073
- var rect = sel.getRangeAt(0).getBoundingClientRect()
1074
- if (!rect || (!rect.width && !rect.height)) return
1075
- ccvLog('selectionchange: 兜底触发(编辑区 mouseup 未生效)')
1076
- updateSelFormats(sel)
1077
- showSelbar(rect)
1078
- } catch (e) {}
1079
- }, 30)
1080
- }
1081
- document.addEventListener('selectionchange', onSelChange)
1082
- return function () { if (t) clearTimeout(t); document.removeEventListener('selectionchange', onSelChange) }
1083
- }, [])
1084
-
1085
- useEffect(() => {
1086
- fetch(canvasApi("/api/canvas/list")).then(r => r.json()).then(d => {
1087
- // pending 时让位:doclink 链接点名要打开的画布优先于默认 activeId
1088
- if (d.ok) { setCanvases(d.canvases); if (d.activeId && !window.__ccvPendingCanvasId) loadCanvas(d.activeId); }
1089
- }).catch(() => {});
1090
- }, []);
1091
-
1092
- // doclink 桥接:外部「文档名链接」点名要打开的画布(优先于默认 activeId),
1093
- // 打开状态下通过事件直接切换
1094
- useEffect(function () {
1095
- var pid = window.__ccvPendingCanvasId;
1096
- if (pid) { window.__ccvPendingCanvasId = null; loadCanvas(pid); }
1097
- function onOpen(e) {
1098
- var id = e.detail && e.detail.id;
1099
- if (!id) return;
1100
- loadCanvas(id);
1101
- // 新导入的画布不在清单里,顺手刷新下拉列表
1102
- fetch(canvasApi("/api/canvas/list")).then(r => r.json()).then(d => {
1103
- if (d.ok) setCanvases(d.canvases);
1104
- }).catch(() => {});
1105
- }
1106
- window.addEventListener('ccv-open-canvas', onOpen);
1107
- return function () { window.removeEventListener('ccv-open-canvas', onOpen); };
1108
- }, []);
1109
-
1110
- // 清单轮询:AI 通过工具创建/加载的画布自动出现在下拉清单里(10 秒一次)
1111
- useEffect(function () {
1112
- var t = setInterval(function () {
1113
- try {
1114
- fetch(canvasApi("/api/canvas/list")).then(r => r.json()).then(d => {
1115
- if (d.ok) setCanvases(d.canvases);
1116
- }).catch(() => {});
1117
- } catch (_) {}
1118
- }, 10000);
1119
- return () => clearInterval(t);
1120
- }, []);
907
+ // 幂等注入一段 CSS:同一个 id 只插一次,重复调用是空操作。
908
+ // apply 有可能被走多遍(宿主重挂插件),靠这里去重。
909
+ function ensureStyle(id, cssText) {
910
+ if (typeof document === 'undefined') return;
911
+ if (document.getElementById(id)) return; // 已注入过,不重复插
912
+ var el = document.createElement('style');
913
+ el.id = id;
914
+ el.textContent = cssText;
915
+ document.head.appendChild(el);
916
+ }
1121
917
 
1122
- // 把编辑器里**还没写出去**的内容立刻落一次。返回 Promise:
1123
- // resolve(null) 没有待写内容(没编辑器 / 内容与服务端一致)
1124
- // resolve({ ok:true,… }) 写成功
1125
- // resolve({ conflict:… }) 服务端版本已前进,本机这一份没写进去
918
+ // ══════════════════════════════════════════════════════════════════════
919
+ // 右侧栏「话布」标签页:整块(2026-10-27 重构 P4,第二次尝试)
920
+ //
921
+ // 第一次尝试(8c019f0)失败了 ✗ 原因记在这里,别重犯:
922
+ // 我把 405 行搬走时,**只核对了"引用了什么",没核对"定义了什么"** ✗
923
+ // 这段里其实**定义了** ccvSessionsSnapshot / ccvSessionTitleOf /
924
+ // ccvHashKeyMap / ccvBuildGroups / ccvFetchGroups / 缓存 … ✗
925
+ // 搬走之后 apply 里引用它们的地方全变成 undefined ✓
926
+ // → "右侧「话布」标签页整个消失" ✓ 而当时的检查一条都没拦住 ✗
927
+ //
928
+ // 这次的做法:
929
+ // · 先用客户端冒烟测试证明"现状是绿的"(tools/test-client-smoke.cjs)✓
930
+ // · 精确算过:区段内**顶层定义 9 个**、apply 区段外**只用到 CcvFeatureTab** ✓
931
+ // · 区段内用到、区段外定义的只有 7 个 → 由 D 传入 ✓
932
+ // · 代码逐字搬运,一行未改 ✓
933
+ function ccvMakeCanvasTab(D) {
934
+ // 这 7 个是区段外定义的(在 apply 里),其余依赖都在模块级 ✓ 直接可见
935
+ var CCV_ROW_BASE = D.CCV_ROW_BASE, CCV_TAB_ID = D.CCV_TAB_ID
936
+ var CcvEntry = D.CcvEntry, CcvErrorBoundary = D.CcvErrorBoundary
937
+ var CcvRow = D.CcvRow, CcvTabGlyph = D.CcvTabGlyph
938
+ var ccvGuideColumn = D.ccvGuideColumn
939
+ var ctx = D.ctx // ← apply 的参数,第一次搬漏了它:ctx.slots 直接抛错被 catch 吞掉 ✗
940
+
941
+ // ─── 右侧栏「话布」标签页(2026-10-24 改成两级)───────────────────
942
+ // 主人原话:「这一页是话布功能选页,清单应该放到话布文档二级菜单里」
943
+ // → 一级页 = **功能入口**(话布文档 / 规划中的功能)
944
+ // 二级页 = **话布文档**(文档清单 ✓ 点一下跳到那篇 ✓ + 返回)
945
+ // ⚠️ 用组件内一个 view 状态切页 ✓ 不需要路由 ✓ 简单可靠 ✓
946
+ // ⚠️ **必须接 props** ✗ —— sessionId 就在这里 ✓(官方 FilesBody 同款 ✓)
947
+ // 我原来写成 CcvFeatureTab() 不带参数 → 永远拿不到 sessionId ✓
1126
948
  //
1127
- // 🔴 2026-09-15 新增,修的是一个**静默丢字**:
1128
- // 以前「重载」和「切走文档」都只做 clearTimeout,把等待中的自动保存**直接丢掉**,
1129
- // 然后拿服务端内容覆盖界面。于是「刚敲完就点重载」= 自己敲的字被取消,
1130
- // 界面又变回旧内容 —— 主人报的「改了却看不到 / 改了又变回去」正是这条路径。
1131
- // clearTimeout 本身没错(它防的是响应乱序),错在**只取消、不落地**。
1132
- // 这里两件事都做:先取消定时器,再亲手把这份内容写出去。
1133
- function flushPending() {
1134
- clearTimeout(saveTimer.current);
1135
- var id = ccvLiveCanvasId;
1136
- var el = editorRef.current;
1137
- if (!id || !el) return Promise.resolve(null);
1138
- var md;
1139
- try { md = htmlToMd(el); } catch (_) { return Promise.resolve(null); }
1140
- // 没动过就不必写(和服务端本来就一致),省一次请求
1141
- if (lastSyncedMd.current === null || md === lastSyncedMd.current) return Promise.resolve(null);
1142
- ccvLog("重载/切走前先落一次待写内容(不再直接丢弃)");
1143
- return writeMd(id, md);
1144
- }
1145
-
1146
- function loadCanvas(id) {
1147
- var sameDoc = (id === ccvLiveCanvasId);
1148
- flushPending().then(function (res) {
1149
- // 本机有内容却没写成功(服务端版本已经前进 → 冲突):
1150
- // 这时候再拿服务端内容覆盖界面,就是把主人正在编辑的东西从他眼前抹掉。
1151
- // 所以**保留界面不动**,只告诉他一声,留哪一份由他定。
1152
- if (sameDoc && res && res.conflict) {
1153
- ccvLog("重载已中止:本机有未提交的编辑,且服务端也有新版本");
1154
- if (window.__ccvKit && window.__ccvKit.toast) {
1155
- window.__ccvKit.toast("你还有没提交的编辑,已为你保留;服务端也有新版本,先确认要留哪一份", "ccv-reload-blocked");
1156
- }
1157
- return;
1158
- }
1159
- doLoad(id);
1160
- }).catch(function () { doLoad(id); });
1161
- }
1162
-
1163
- function doLoad(id) {
1164
- fetch(canvasApi("/api/canvas/read?id=" + encodeURIComponent(id))).then(r => r.json()).then(d => {
1165
- if (!d.ok) return;
1166
- var dft = draftGet(d.id);
1167
- if (dft && dft.content !== d.content) {
1168
- // ⚠️ 草稿必须先验「基准版本」再用。
1169
- // 草稿是「本机还没写回服务端」的那一版;但如果服务端在草稿产生之后前进过
1170
- // (AI 改了文档、或另一处写了新内容),把草稿写回去就是用旧内容覆盖新内容。
1171
- // 两次「话布优化」被整段抹掉都是这条路径干的:
1172
- // 恢复草稿 → 用服务端的当前版本当 knownVer → 版本校验通过 → 覆盖成功。
1173
- var dbase = (typeof dft.base === "number") ? dft.base : null;
1174
- if (dbase === null || dbase !== d.version) {
1175
- // 基准对不上(含没有 base 的旧格式草稿)→ 不自动写回,只载入服务端内容。
1176
- // 草稿仍留在 localStorage 里没被删,用户的内容不会丢。
1177
- ccvLog("草稿基准不符(草稿 v" + dbase + " / 服务端 v" + d.version + "),不自动写回: id=" + d.id);
1178
- setActiveId(d.id); setContent(d.content); setVersion(d.version);
1179
- versionRef.current = d.version; lastSyncedMd.current = d.content;
1180
- setRestored(false);
1181
- // 只提示一次:草稿会一直躺在 localStorage 里,每打开一次都弹就成了噪音,
1182
- // 而且用户看了也无事可做。保护照旧(每次都不写回),只是不再重复告知。
1183
- if (!dft.notified) {
1184
- draftMarkNotified(d.id);
1185
- if (window.__ccvKit && window.__ccvKit.toast) {
1186
- window.__ccvKit.toast("已跳过一份过期的本地草稿,当前显示的是最新版本", "ccv-draft-stale");
1187
- }
1188
- }
1189
- return;
1190
- }
1191
- // 有一份没来得及写盘的草稿:它是用户刚敲的,比服务端那版新,优先恢复它。
1192
- // 恢复后按「停止输入 1.5 秒自动存」的同一规矩补写回服务端,
1193
- // 否则用户切走之后,这段内容只活在浏览器里,AI 在对话里看不到。
1194
- ccvLog("恢复未保存草稿: id=" + d.id + " 长度=" + dft.content.length + " 服务端长度=" + d.content.length);
1195
- setActiveId(d.id); setContent(dft.content); setVersion(d.version);
1196
- // 已知版本 = 服务端的版本;已同步内容 = 服务端那份(≠ 本地草稿,
1197
- // 冲突判定据此认出「本机有未保存编辑」)
1198
- versionRef.current = d.version; lastSyncedMd.current = d.content;
1199
- setRestored(true);
1200
- clearTimeout(saveTimer.current);
1201
- saveTimer.current = setTimeout(function () { writeMd(d.id, dft.content); }, 1500);
1202
- } else {
1203
- setActiveId(d.id); setContent(d.content); setVersion(d.version);
1204
- versionRef.current = d.version; lastSyncedMd.current = d.content;
1205
- setRestored(false);
1206
- }
1207
- }).catch(() => {});
1208
- }
1209
-
1210
- // 删除话布:移出清单并删除对应 md 文件(host 端 /api/canvas/delete)
1211
- function deleteCanvasById(id, title) {
1212
- if (!confirm("确定删除话布「" + title + "」吗?\n对应的 md 文件也会一并删除,不可恢复。")) return;
1213
- fetch(canvasApi("/api/canvas/delete?id=" + encodeURIComponent(id))).then(r => r.json()).then(d => {
1214
- if (d.ok) {
1215
- setCanvases(prev => prev.filter(x => x.id !== id));
1216
- if (activeId === id) { setActiveId(null); setContent(""); setVersion(0); versionRef.current = 0; lastSyncedMd.current = null; }
1217
- } else alert("删除失败: " + (d.error || "未知错误"));
1218
- }).catch(() => alert("删除失败"));
1219
- }
1220
-
1221
- // 并发冲突处理(host 端 applyWrite 检出 baseVersion 不匹配时回调到这里)。
1222
- // AI 在对话里改过这份文档 → 服务端版本已经前进,而我们手里是旧内容。
1223
- // 关键是别把正在编辑的人的内容丢掉,所以分两种情形:
1224
- // ① 本机没有未保存编辑(DOM 内容 === 最后一次与服务端一致的内容)
1225
- // → 采用服务端版本。这正是「AI 写入被编辑器覆盖」那个 bug 的修复点。
1226
- // ② 本机有编辑 → 不覆盖,只提示,交给用户决定(点「重载」拿最新)。
1227
- function resolveConflict(localMd, d) {
1228
- var serverMd = typeof d.currentContent === "string" ? d.currentContent : "";
1229
- var base = lastSyncedMd.current;
1230
- if (base !== null && localMd === base) {
1231
- ccvLog("冲突: 本机无改动 -> 采用服务端 v" + d.currentVersion);
1232
- setContent(serverMd);
1233
- setVersion(d.currentVersion);
1234
- versionRef.current = d.currentVersion;
1235
- lastSyncedMd.current = serverMd;
1236
- setRestored(false);
1237
- if (window.__ccvKit && window.__ccvKit.toast) window.__ccvKit.toast("这份话布已被 AI 更新,已同步到最新版本");
1238
- } else {
1239
- ccvLog("冲突: 本机有未保存编辑 -> 保留本地,不覆盖");
1240
- if (window.__ccvKit && window.__ccvKit.toast) window.__ccvKit.toast("这份话布已被 AI 更新;你的编辑尚未提交,点「重载」可查看最新内容", "ccv-conflict-toast");
1241
- }
1242
- }
1243
-
1244
- // 写盘。接受显式的 md:卸载后 / 恢复草稿时编辑器 DOM 已不可靠,
1245
- // 这两种情况下必须用调用方手里那份内容,不能再回头读 DOM。
1246
- function writeMd(id, md) {
1247
- if (!id || typeof md !== "string") return Promise.resolve(null);
1248
- return fetch(canvasApi("/api/canvas/write"), {
1249
- method: "POST", headers: { "Content-Type": "application/json" },
1250
- // knownVer:带上去,host 端 applyWrite 才能检出「我们基于旧版本写」的并发冲突。
1251
- // 不带的话 typeof baseVersion !== 'number' → 直接跳过校验 → 后写覆盖先写。
1252
- body: JSON.stringify({ id: id, content: md, knownVer: versionRef.current })
1253
- }).then(r => r.json()).then(function (d) {
1254
- // ⚠️ 只有「写的还是当前打开的这一篇」时才更新本地基准。
1255
- // 「重载/切走前先落一次」会在切换途中发请求,响应回来时可能已经在看别的文档了;
1256
- // 那时若照旧更新,就会把**旧文档**的版本号盖到**新文档**身上。
1257
- var still = (id === ccvLiveCanvasId);
1258
- if (d && d.ok) {
1259
- if (still) {
1260
- setVersion(d.version);
1261
- versionRef.current = d.version;
1262
- lastSyncedMd.current = md;
1263
- setRestored(false);
1264
- }
1265
- draftDropIf(id, md);
1266
- } else if (d && d.conflict) {
1267
- if (still) resolveConflict(md, d);
1268
- }
1269
- return d;
1270
- }).catch(function () { return null; });
1271
- }
1272
-
1273
- function saveCanvas() {
1274
- if (!activeId || !editorRef.current) return;
1275
- writeMd(activeId, htmlToMd(editorRef.current));
949
+ // ★ 2026-10-24 第二轮:清单**按会话分组** ★
950
+ // 主人原话:「清单页做分组,按会话名来分组,把当前会话的组置顶并展开,
951
+ // 别的会话默认收起」
952
+ // 数据:/api/canvas/list?scope=groups ✓(本轮新增的分支 ✓)
953
+ // groups: [{ sid, activeId, ids }] canvases: 全量元数据
954
+ // 会话名:ctx.sessions.list.getSnapshot().byId[sid].displayTitle ✓
955
+ // —— 官方里就是这么命令式读的(client.js 里到处是
956
+ // `ctx.sessions.list.getSnapshot().byId[...]`)✓
957
+ // ⚠️ 不用 useSessions 那个 hook ✓ 免得在"可能不存在"的 prop 上
958
+ // 做条件式 hook 调用(那会违反 hook 规则 ✓ 上次就是这么白的 ✓)
959
+ // 默认展开:**只有当前会话**(s.open 里没有这个 sid 时,看它是不是当前会话 ✓)
960
+ // → 这样不需要在数据到达时初始化状态 ✓ 少一处时序 bug ✓
961
+ // 把接口数据按会话分好组 ✓(当前会话置顶 ✓ 其余按最新更新倒序 ✓)
962
+ //
963
+ // ★ 2026-10-24:旧哈希键「反着撞一遍」★
964
+ // 老键 = "sess-" + hash(侧栏里那行会话名) ✓ 哈希虽单向,
965
+ // 但**名字我这儿有**(ctx.sessions.list 里就有 displayTitle)✓
966
+ // → 对每个已知会话算一遍 hash(displayTitle),撞上哪个旧键,那组就是它 ✓
967
+ // 算法与输入已被日志实证(hash("DeepSeek Harness") = qberq7 ✓
968
+ // hash("默认会话") = 1nbvho8 ✓ 都对上了)✓
969
+ // 撞不上的(default、老命名 xxx-hash)→ 显示「旧记录」✓ 由拖动解决 ✓
970
+ function ccvSessionsSnapshot() {
971
+ try {
972
+ var snap = ctx.sessions && ctx.sessions.list && ctx.sessions.list.getSnapshot
973
+ ? ctx.sessions.list.getSnapshot() : null
974
+ return (snap && snap.byId) || null
975
+ } catch (_) { return null }
1276
976
  }
1277
-
1278
- // 在系统文件管理器里打开当前文档所在的文件夹(host 端调 explorer/open/xdg-open)
1279
- function openFolder() {
1280
- fetch("/api/canvas/open-folder", { method: "POST" })
1281
- .then(r => r.json())
1282
- .then(d => { ccvLog("open-folder 响应: " + JSON.stringify(d)); })
1283
- .catch(e => { ccvLog("open-folder 请求失败: " + e); });
977
+ function ccvSessionTitleOf(byId, sid) {
978
+ try {
979
+ var row = byId ? byId[sid] : null
980
+ if (row) return row.displayTitle || row.title || ''
981
+ } catch (_) {}
982
+ return ''
1284
983
  }
1285
-
1286
- // 「登记」:管理拖拽文件来源文件夹(host 端文件登记表,命中后按真实路径 @ 引用)
1287
- function manageRegistry() {
1288
- fetch("/api/canvas/registry").then(r => r.json()).then(d => {
1289
- if (!d.ok) { alert("读取登记信息失败"); return; }
1290
- const folders = d.folders || [];
1291
- const tip = folders.length
1292
- ? folders.map((f, i) => (i + 1) + ". " + f).join("\n") + "\n\n输入序号移除对应文件夹;或直接输入新的文件夹绝对路径添加:"
1293
- : "尚未登记文件夹。\n输入要登记的文件夹绝对路径(之后拖拽该文件夹内的文件时,将按真实路径 @ 引用):";
1294
- const ans = prompt("已登记 " + folders.length + " 个文件夹,共索引 " + (d.files || 0) + " 个文件。\n\n" + tip, "");
1295
- if (ans === null) return;
1296
- const t = ans.trim();
1297
- let next;
1298
- const idx = /^\d+$/.test(t) ? parseInt(t, 10) - 1 : -1;
1299
- if (idx >= 0 && idx < folders.length) {
1300
- next = folders.filter((_, i) => i !== idx);
1301
- } else if (t) {
1302
- next = folders.concat([t]);
1303
- } else {
1304
- return;
1305
- }
1306
- fetch("/api/canvas/registry", {
1307
- method: "POST", headers: { "Content-Type": "application/json" },
1308
- body: JSON.stringify({ folders: next })
1309
- }).then(r => r.json()).then(d2 => {
1310
- if (d2.ok) alert("已更新登记文件夹(" + (d2.folders || []).length + " 个,索引 " + (d2.files || 0) + " 个文件,构建中)");
1311
- else alert("更新失败: " + (d2.error || "未知错误"));
1312
- }).catch(() => alert("更新失败"));
1313
- }).catch(() => alert("读取登记信息失败"));
984
+ // 旧哈希键 → 真 sessionId(用已知会话名反算)
985
+ // ⚠️ 2026-10-26:算法已搬进 lib/ccv-logic.js ✓ 这里只做委托
986
+ function ccvHashKeyMap(byId) {
987
+ var L = ccvLogic()
988
+ return L ? L.hashKeyMap(byId) : {}
1314
989
  }
1315
-
1316
- // ─── 顶栏:新建话布(行内输入框,替掉原生 prompt)──
1317
- function startCreate() { setDropOpen(false); setNewTitle(""); setCreating(true); }
1318
- function cancelCreate() { setCreating(false); setNewTitle(""); }
1319
- function submitCreate() {
1320
- const t = newTitle.trim();
1321
- if (!t) { cancelCreate(); return; }
1322
- fetch(canvasApi("/api/canvas/create"), {
1323
- method: "POST", headers: { "Content-Type": "application/json" },
1324
- body: JSON.stringify({ title: t })
1325
- }).then(r => r.json()).then(d => {
1326
- if (d.ok) {
1327
- setCanvases(prev => prev.concat([{ id: d.id, title: d.title }]));
1328
- loadCanvas(d.id);
1329
- }
1330
- cancelCreate();
1331
- }).catch(() => { cancelCreate(); });
990
+ // 分组拼装(当前会话置顶 / 旧键反查合并 / 未归属垫底)
991
+ // ⚠️ 同样是委托 ✓ 同一套规则有 60 条 Node 断言守着 ✓
992
+ function ccvBuildGroups(data, curSid, byId) {
993
+ var L = ccvLogic()
994
+ if (!L) return { groups: [], total: (data && data.canvases || []).length }
995
+ return L.buildGroups(data, curSid, byId, function (sid) { return ccvSessionTitleOf(byId, sid) })
1332
996
  }
1333
- function toggleDrop() { setCreating(false); setDropOpen(v => !v); }
1334
997
 
1335
- // 点下拉/胶囊以外的地方关闭下拉;Esc 同时取消新建
1336
- useEffect(() => {
1337
- if (!dropOpen && !creating) return;
1338
- function onDown(e) {
1339
- const t = e.target;
1340
- if (t && t.closest && (t.closest(".ccv-drop") || t.closest(".ccv-chip") || t.closest(".ccv-ninput"))) return;
1341
- setDropOpen(false);
1342
- cancelCreate();
998
+ // ★ 清单数据缓存 + 预取(2026-10-24 修「别的会话里要十多秒」)★
999
+ // 实测证据:
1000
+ // 当前会话 清单加载耗时 11ms ✓
1001
+ // 刚换会话 清单加载耗时 9288ms ✗(同样的 14 组 / 20 篇 ✓ 接口一样快 ✓)
1002
+ // 机制:**换会话那一刻请求被排在队尾** ✓(宿主/浏览器主线程正忙 ✓)
1003
+ // —— 不是我们的接口慢 ✓ 也不是渲染慢 ✓
1004
+ // 但有一个事实可以利用:`?scope=groups` 的返回**与会话无关** ✓
1005
+ // (主机端这个分支压根不看 sid ✓ 全部会话 + 全部文档 ✓)
1006
+ // → 于是:① 进标签页就【预取】✓ 不等点开二级页
1007
+ // ② 20 秒内再用【直接用缓存】✓ 不再发请求
1008
+ // ③ 缓存过期也先显示旧数据 ✓ 后台静默刷新 ✓(不让人对着"读取中"✓)
1009
+ var CCV_GROUPS_TTL = 20000
1010
+ var ccvGroupsCache = { at: 0, data: null, err: '' }
1011
+ function ccvFetchGroups(force) {
1012
+ var now = Date.now()
1013
+ if (!force && ccvGroupsCache.data && (now - ccvGroupsCache.at) < CCV_GROUPS_TTL) {
1014
+ return Promise.resolve({ data: ccvGroupsCache.data, err: '', ms: 0, cached: true })
1343
1015
  }
1344
- function onKey(e) { if (e.key === "Escape") { setDropOpen(false); cancelCreate(); } }
1345
- document.addEventListener("mousedown", onDown);
1346
- document.addEventListener("keydown", onKey);
1347
- return () => {
1348
- document.removeEventListener("mousedown", onDown);
1349
- document.removeEventListener("keydown", onKey);
1350
- };
1351
- }, [dropOpen, creating]);
1352
-
1353
- // ─── Markdown ↔ HTML ──────────────────────────
1354
- // ⚠️ htmlToMd / mdToHtml 已搬到模块级,见文件上方「Markdown ↔ HTML 转换(纯函数)」。
1355
- // 找不到不是被删了 —— 往上翻。
1356
-
1357
- // ↑ 已提到模块级:见文件上方「静态资源(模块级)」
1358
- // ─── 划词栏(参考豆包样式)──────────────────────
1359
- // 选区暂存。用 ref(savedRangeRef,声明在组件顶部的 ref 区),**不能用普通变量**:
1360
- // 写在组件函数体里的变量每次重渲染都会被重新初始化,而这里存在**真正的跨渲染访问** ——
1361
- // 下面 showDropdownAt 里注册的「点外部关闭」监听是异步的(setTimeout + mousedown 事件),
1362
- // 它会写这个变量;中间只要发生过一次重渲染,那次写入就落进了旧闭包,
1363
- // 当前渲染读到的还是旧值 → 之后 restoreSelection 会把内容加粗到**旧位置**。
1364
- // 改成 ref 后:所有渲染共享同一个对象(不再有新旧闭包之分),
1365
- // 同时保持"每个面板实例各存各的"——悬浮与停靠并存时不会互相覆盖。
1366
-
1367
- function saveSelection() {
1368
- var sel = document.getSelection()
1369
- if (sel && sel.rangeCount > 0) savedRangeRef.current = sel.getRangeAt(0).cloneRange()
1370
- }
1371
- function restoreSelection() {
1372
- if (!savedRangeRef.current) return
1373
- var sel = document.getSelection()
1374
- if (sel) { sel.removeAllRanges(); sel.addRange(savedRangeRef.current) }
1375
- savedRangeRef.current = null
1376
- }
1377
-
1378
- function closeDropdowns() {
1379
- var existing = document.querySelectorAll('.ccv-dropdown')
1380
- existing.forEach(function(d) { d.remove() })
1016
+ var t0 = Date.now()
1017
+ return fetch('/api/canvas/list?scope=groups', { cache: 'no-store' })
1018
+ .then(function (r) { return r.json() })
1019
+ .then(function (d) {
1020
+ var data = { canvases: (d && d.canvases) || [], groups: (d && d.groups) || [] }
1021
+ var err = (d && d.ok === false) ? (d.error || '读不到') : ''
1022
+ ccvGroupsCache = { at: Date.now(), data: data, err: err }
1023
+ return { data: data, err: err, ms: Date.now() - t0, cached: false }
1024
+ })
1025
+ .catch(function (e) {
1026
+ return { data: null, err: (e && e.message) || '读不到', ms: Date.now() - t0, cached: false }
1027
+ })
1381
1028
  }
1382
-
1383
- // 菜单在任意屏幕坐标打开(右键菜单用);showDropdown 走 anchor 定位也归到这里
1384
- function openDropdownAt(left, top, items, onSelect) {
1385
- closeDropdowns()
1386
- saveSelection()
1387
- var dd = document.createElement('div')
1388
- dd.className = 'ccv-dropdown'
1389
- // 黑白极简:不透明底、细灰边、轻阴影,无毛玻璃无彩色
1390
- dd.style.cssText = 'position:fixed;z-index:2147483001;background:var(--dsw-alias-bg-overlay,#fff);border:1px solid var(--dsw-alias-border-l1,rgba(128,128,128,.35));border-radius:8px;box-shadow:0 6px 20px rgba(0,0,0,.1);padding:4px 0;min-width:160px;max-width:320px;font-size:13px;color:var(--dsw-alias-label-primary,#1a1a1a);max-height:72vh;overflow-y:auto'
1391
- // 图标:SVG 用 innerHTML(线条图标),纯文本(如 ¹)用 textContent
1392
- function iconSpan(icon) {
1393
- var ic = document.createElement('span')
1394
- if (icon.indexOf('<svg') === 0) ic.innerHTML = icon
1395
- else ic.textContent = icon
1396
- ic.style.cssText = 'width:18px;display:flex;justify-content:center;flex:0 0 auto'
1397
- return ic
1029
+ function CcvFeatureTab(props) {
1030
+ var sessionId = (props && props.sessionId) || ''
1031
+ // 初始就吃缓存 ✓(第二次进标签页 = 立刻有内容 ✓ 不用等 ✓)
1032
+ var st = React.useState({
1033
+ view: 'home', loading: !ccvGroupsCache.data, err: ccvGroupsCache.err,
1034
+ data: ccvGroupsCache.data, open: {}, rev: 0,
1035
+ })
1036
+ var s = st[0], setS = st[1]
1037
+ // ★ 把真 sessionId 挂到全局 ✓(2026-10-24)
1038
+ // 为什么需要:sessionKey() 现在优先读它 ✓
1039
+ // 写入这一侧(打开/新建文档记到哪个会话)才是真会话 ✓
1040
+ // —— 不挂的话,标签页里根本没打开过文档时,仍会退回哈希键 ✗
1041
+ React.useEffect(function () {
1042
+ try { if (sessionId) window.__ccvSessionId = sessionId } catch (_) {}
1043
+ }, [sessionId])
1044
+ // ★ 预取(进标签页就拉 ✓ 不等点进二级页 ✓)★
1045
+ // 换会话那 10 秒里请求会排在队尾 ✗ 所以在【安静的时候】先把它拿下来 ✓
1046
+ React.useEffect(function () {
1047
+ var alive = true
1048
+ ccvFetchGroups(false).then(function (r) {
1049
+ if (!alive || r.cached) return
1050
+ ccvLog('清单预取 ' + r.ms + 'ms' + (r.err ? (' ✗ ' + r.err) : ' ✓'))
1051
+ setS(function (p) {
1052
+ if (!r.data) return p
1053
+ return Object.assign({}, p, { data: r.data, loading: false, err: r.err })
1054
+ })
1055
+ })
1056
+ return function () { alive = false }
1057
+ }, [sessionId])
1058
+ // 切到二级页 + 拖动后刷新 ✓(有缓存就直接显示 ✓ 过期才真去拉 ✓)
1059
+ React.useEffect(function () {
1060
+ if (s.view !== 'docs') return
1061
+ if (s.data && !s.rev) return // 有缓存且不是拖动后 → 不用再拉 ✓
1062
+ var alive = true
1063
+ var force = !!s.rev // 拖动后必须真拉 ✓
1064
+ ccvFetchGroups(force).then(function (r) {
1065
+ if (!alive) return
1066
+ ccvLog('清单加载耗时 ' + r.ms + 'ms' + (r.cached ? '(缓存)' : '')
1067
+ + ' | 组 ' + ((r.data && r.data.groups) || []).length
1068
+ + ' 个 | 文档 ' + ((r.data && r.data.canvases) || []).length + ' 篇'
1069
+ + (r.err ? (' ✗ ' + r.err) : ''))
1070
+ setS(function (p) {
1071
+ if (!r.data) return Object.assign({}, p, { loading: false, err: r.err })
1072
+ return Object.assign({}, p, { data: r.data, loading: false, err: r.err })
1073
+ })
1074
+ })
1075
+ return function () { alive = false }
1076
+ }, [s.view, s.rev])
1077
+ // ★ 拖动改分组(2026-10-24 主人要求「我要能自由拖动改分组」)★
1078
+ // 只调一个主机端点 ✓ 主机端只改 .sessions/*.json 的 id 数组 ✓
1079
+ // **文件、路径、内容一律不碰** ✓(主人已确认这条 ✓)
1080
+ var moveTo = function (docId, toSid) {
1081
+ if (!docId) return
1082
+ ccvLog('拖动分组:' + docId + ' → ' + (toSid || '(移出所有组)'))
1083
+ fetch('/api/canvas/move-group', {
1084
+ method: 'POST',
1085
+ headers: { 'Content-Type': 'application/json' },
1086
+ body: JSON.stringify({ id: docId, toSid: toSid || '' }),
1087
+ cache: 'no-store',
1088
+ })
1089
+ .then(function (r) { return r.json() })
1090
+ .then(function (d) {
1091
+ if (d && d.ok === false) ccvLog('拖动失败 ✗ ' + (d.error || ''))
1092
+ else ccvLog('拖动完成 ✓ 从 ' + ((d && d.removed) || 0) + ' 个组移出')
1093
+ setS(function (p) { return Object.assign({}, p, { rev: (p.rev || 0) + 1 }) })
1094
+ })
1095
+ .catch(function (e) {
1096
+ ccvLog('拖动请求失败 ✗ ' + (e && e.message))
1097
+ setS(function (p) { return Object.assign({}, p, { rev: (p.rev || 0) + 1 }) })
1098
+ })
1398
1099
  }
1399
- function addRow(item) {
1400
- var row = document.createElement('div')
1401
- row.style.cssText = 'padding:7px 14px;cursor:pointer;display:flex;align-items:center;gap:9px;transition:background .1s;border-radius:0;white-space:nowrap'
1402
- if (item.icon) row.appendChild(iconSpan(item.icon))
1403
- var lb = document.createElement('span')
1404
- lb.textContent = item.label
1405
- row.appendChild(lb)
1406
- if (item.style) row.style.cssText += ';' + item.style
1407
- // 选中态:黑白线条风 = 1px currentColor 描边 + 加粗,不用彩色
1408
- if (item.active) { row.style.boxShadow = 'inset 0 0 0 1px currentColor'; row.style.fontWeight = '600' }
1409
- row.addEventListener('mouseenter', function() { row.style.background = 'rgba(128,128,128,.12)' })
1410
- row.addEventListener('mouseleave', function() { row.style.background = 'transparent' })
1411
- row.addEventListener('click', function(e) { e.stopPropagation(); closeDropdowns(); onSelect(item) })
1412
- dd.appendChild(row)
1100
+ var card = { border: '1px solid var(--dsw-alias-border-l1,rgba(128,128,128,.3))', borderRadius: '8px', padding: '10px 12px', marginBottom: '8px' }
1101
+ var dim = { color: 'var(--dsw-alias-label-secondary,#6b7280)', fontSize: '12px', lineHeight: 1.6 }
1102
+ var go = function (view) {
1103
+ setS(function (p) { return Object.assign({}, p, { view: view }) })
1413
1104
  }
1414
- items.forEach(function(item) {
1415
- if (item.type === 'sep') {
1416
- var sep = document.createElement('div')
1417
- sep.style.cssText = 'height:1px;background:rgba(128,128,128,.15);margin:4px 0'
1418
- dd.appendChild(sep)
1419
- return
1420
- }
1421
- if (item.type === 'header') {
1422
- // 分组标题:不可点,小号灰字、加字距
1423
- var hd = document.createElement('div')
1424
- hd.style.cssText = 'padding:6px 14px 2px;font-size:11px;color:rgba(128,128,128,.75);letter-spacing:.5px;user-select:none;-webkit-user-select:none;white-space:nowrap'
1425
- hd.textContent = item.label
1426
- dd.appendChild(hd)
1427
- return
1105
+ // ── 二级页:话布文档(清单)──
1106
+ // ★ 骨架照官方文件树(FilesBody)★(2026-10-24 主人要求"按这个文件列表的显示风格")
1107
+ // root height:100%; min-height:0; display:flex; flex-direction:column;
1108
+ // font-size: var(--dsh-content-font-size-secondary, 13px); line-height:1.5
1109
+ // header height:38px; border-bottom:.5px solid border-l3;
1110
+ // align-items:center; gap:4px; padding:0 6px 0 16px; flex:none
1111
+ // body flex:auto; min-height:0; overflow:auto;
1112
+ // padding:8px 0 8px 8px; margin-right:2px;
1113
+ // scrollbar-gutter:stable ← 滚动条出现时不跳
1114
+ // ⚠️ 不是居中卡片列 ✗ —— 文件树是**贴左、通栏**的 ✓
1115
+ if (s.view === 'docs') {
1116
+ // 按会话分组 ✓(当前会话置顶 ✓ 其余默认收起 ✓)
1117
+ // ★ 构建计时(只记慢的 ✓ 不刷屏)★
1118
+ var tb0 = Date.now()
1119
+ var model = ccvBuildGroups(s.data, sessionId, ccvSessionsSnapshot())
1120
+ var buildMs = Date.now() - tb0
1121
+ if (buildMs >= 30) ccvLog('清单构建耗时 ' + buildMs + 'ms | 组 ' + model.groups.length + ' 个')
1122
+ var isOpen = function (sid) {
1123
+ if (Object.prototype.hasOwnProperty.call(s.open || {}, sid)) return !!s.open[sid]
1124
+ return sid === sessionId // 默认只展开当前会话 ✓
1428
1125
  }
1429
- if (item.type === 'row') {
1430
- // 横排:多个紧凑按钮挤一行(块区四个常用项),省菜单高度
1431
- var rr = document.createElement('div')
1432
- rr.style.cssText = 'display:flex;align-items:center;gap:2px;padding:2px 8px'
1433
- item.items.forEach(function(sub) {
1434
- var b = document.createElement('div')
1435
- 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'
1436
- if (sub.icon) b.appendChild(iconSpan(sub.icon))
1437
- var sl = document.createElement('span')
1438
- sl.textContent = sub.label
1439
- b.appendChild(sl)
1440
- b.addEventListener('mouseenter', function() { b.style.background = 'rgba(128,128,128,.12)' })
1441
- b.addEventListener('mouseleave', function() { b.style.background = 'transparent' })
1442
- b.addEventListener('click', function(e) { e.stopPropagation(); closeDropdowns(); onSelect(sub) })
1443
- rr.appendChild(b)
1126
+ var toggleGroup = function (sid) {
1127
+ setS(function (p) {
1128
+ var nx = Object.assign({}, p.open || {})
1129
+ nx[sid] = !isOpen(sid)
1130
+ return Object.assign({}, p, { open: nx })
1444
1131
  })
1445
- dd.appendChild(rr)
1446
- return
1447
1132
  }
1448
- addRow(item)
1449
- })
1450
- document.body.appendChild(dd)
1451
- // 先挂载量尺寸,再夹到视口内;下方放不下就翻到坐标上方
1452
- var ddWidth = dd.offsetWidth
1453
- var ddHeight = dd.offsetHeight
1454
- var x = Math.max(8, Math.min(left, window.innerWidth - ddWidth - 8))
1455
- var y = top
1456
- if (y + ddHeight > window.innerHeight - 8) y = Math.max(8, top - ddHeight - 4)
1457
- dd.style.left = x + 'px'
1458
- dd.style.top = y + 'px'
1459
- // 点击外部关闭;没点菜单就关掉的话,保存的选区一并作废(防止之后误插到旧位置)
1460
- setTimeout(function() {
1461
- document.addEventListener('mousedown', function close(ev) {
1462
- if (!dd.contains(ev.target)) { closeDropdowns(); savedRangeRef.current = null; document.removeEventListener('mousedown', close) }
1133
+ var rows = model.groups.map(function (g) {
1134
+ var open = isOpen(g.sid)
1135
+ var isCur = (g.sid === sessionId)
1136
+ var isOrphan = (g.sid === '__orphan__')
1137
+ // 名字:真会话名 ✓;旧键反查成功的也用真名 ✓;
1138
+ // 认不出的 → 「旧记录 · <键>」✓ 不再截断成 _Harness 那种碎片 ✗
1139
+ var title = g.title
1140
+ || (isOrphan ? '未归属' : ('旧记录 · ' + g.sid))
1141
+ // 组头:chevron + 会话名 + 篇数(当前会话那组给个标记 ✓ 一眼能认出来 ✓)
1142
+ var head = h('div', {
1143
+ key: 'h:' + g.sid,
1144
+ onClick: function () { toggleGroup(g.sid) },
1145
+ title: isOrphan ? '不属于任何会话的文档' : (g.legacy ? ('旧键:' + g.sid + '(把文档拖到别的组即可)') : ('会话:' + (g.title || g.sid))),
1146
+ role: 'button',
1147
+ // ★ 拖到这里 = 归到这一组 ✓(未归属 = 移出所有组 ✓)
1148
+ onDragOver: function (e) {
1149
+ try {
1150
+ e.preventDefault()
1151
+ e.dataTransfer.dropEffect = 'move'
1152
+ e.currentTarget.style.background = 'var(--dsw-alias-brand-primary,rgba(59,130,246,.18))'
1153
+ e.currentTarget.style.outline = '.5px dashed var(--dsw-alias-brand-primary,#3b82f6)'
1154
+ } catch (_) {}
1155
+ },
1156
+ onDragLeave: function (e) {
1157
+ try {
1158
+ e.currentTarget.style.background = '0 0'
1159
+ e.currentTarget.style.outline = 'none'
1160
+ } catch (_) {}
1161
+ },
1162
+ onDrop: function (e) {
1163
+ try {
1164
+ e.preventDefault()
1165
+ e.currentTarget.style.background = '0 0'
1166
+ e.currentTarget.style.outline = 'none'
1167
+ var id = e.dataTransfer.getData('text/plain')
1168
+ if (!id) return
1169
+ // ⚠️ 落盘目标优先用【真会话 id】✓(2026-10-24)
1170
+ // 旧哈希组若已反查出真会话(g.matched),就写到真会话名下 ✓
1171
+ // —— 否则拖进去的文档还挂在老哈希键里 ✗ 名字永远救不回来 ✗
1172
+ var dest
1173
+ if (isOrphan) dest = '' // 移出所有组 ✓
1174
+ else if (g.matched || (g.sid && String(g.sid).indexOf('session-') === 0)) dest = g.sid
1175
+ else dest = (g.key || g.sid) // 认不出的旧组:按原键写 ✓
1176
+ moveTo(id, dest)
1177
+ } catch (_) {}
1178
+ },
1179
+ style: Object.assign({}, CCV_ROW_BASE, { cursor: 'pointer' }),
1180
+ onMouseEnter: function (e) {
1181
+ try { e.currentTarget.style.background = 'var(--dsw-alias-interactive-bg-hover,var(--dsw-alias-bg-layer-2,rgba(128,128,128,.12)))' } catch (_) {}
1182
+ },
1183
+ onMouseLeave: function (e) {
1184
+ try { e.currentTarget.style.background = '0 0' } catch (_) {}
1185
+ },
1186
+ },
1187
+ // chevron(收起 ▸ / 展开 ▾)—— 用文字三角,跟随主题色 ✓
1188
+ h('span', {
1189
+ style: {
1190
+ flex: 'none', width: '12px', textAlign: 'center',
1191
+ fontSize: '10px', lineHeight: 1,
1192
+ color: 'var(--dsw-alias-label-tertiary,#6b7280)',
1193
+ },
1194
+ }, open ? '▾' : '▸'),
1195
+ h('span', {
1196
+ style: {
1197
+ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
1198
+ fontWeight: isCur ? 600 : 400,
1199
+ color: g.legacy ? 'var(--dsw-alias-label-secondary,#8b8b8b)' : undefined,
1200
+ },
1201
+ }, title),
1202
+ h('span', {
1203
+ style: {
1204
+ marginLeft: 'auto', flex: 'none', paddingLeft: '8px',
1205
+ fontSize: '12px', color: 'var(--dsw-alias-label-tertiary,#6b7280)',
1206
+ },
1207
+ }, String(g.items.length)))
1208
+ if (!open) return head
1209
+ // 展开:组内文档(官方嵌套缩进 18px ✓)
1210
+ return h('div', { key: g.sid },
1211
+ head,
1212
+ h('div', { style: { paddingLeft: '18px' } },
1213
+ g.items.map(function (c) {
1214
+ return h(CcvRow, { key: c.id, canvas: c, sessionId: sessionId }) // ← 组件调用 ✓
1215
+ })))
1463
1216
  })
1464
- }, 0)
1217
+ return h('div', {
1218
+ style: {
1219
+ boxSizing: 'border-box', height: '100%', minHeight: 0,
1220
+ display: 'flex', flexDirection: 'column',
1221
+ color: 'var(--dsw-alias-label-primary,#1a1a1a)',
1222
+ fontSize: 'var(--dsh-content-font-size-secondary,13px)',
1223
+ lineHeight: 1.5,
1224
+ },
1225
+ },
1226
+ // ── 表头(官方 .header ✓ 38px + 半像素下边线)──
1227
+ h('div', {
1228
+ style: {
1229
+ boxSizing: 'border-box', flex: 'none',
1230
+ display: 'flex', alignItems: 'center', gap: '4px',
1231
+ height: '38px', padding: '0 6px 0 16px',
1232
+ borderBottom: '.5px solid var(--dsw-alias-border-l3,rgba(128,128,128,.28))',
1233
+ },
1234
+ },
1235
+ h('button', {
1236
+ type: 'button',
1237
+ onClick: function () { go('home') },
1238
+ title: '返回功能页',
1239
+ // 官方 .tool:28×28 / 圆角 sm / 悬停才有底色 ✓
1240
+ style: {
1241
+ width: '28px', height: '28px', flex: 'none',
1242
+ display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
1243
+ padding: '6px', lineHeight: 1,
1244
+ background: '0 0', border: 'none', cursor: 'pointer',
1245
+ borderRadius: 'var(--dsw-radius-sm,4px)',
1246
+ color: 'var(--dsw-alias-label-secondary,#8b8b8b)',
1247
+ fontSize: '15px',
1248
+ },
1249
+ onMouseEnter: function (e) {
1250
+ try {
1251
+ e.currentTarget.style.color = 'var(--dsw-alias-label-primary,#1a1a1a)'
1252
+ e.currentTarget.style.background = 'var(--dsw-alias-interactive-bg-hover,rgba(128,128,128,.12))'
1253
+ } catch (_) {}
1254
+ },
1255
+ onMouseLeave: function (e) {
1256
+ try {
1257
+ e.currentTarget.style.color = 'var(--dsw-alias-label-secondary,#8b8b8b)'
1258
+ e.currentTarget.style.background = '0 0'
1259
+ } catch (_) {}
1260
+ },
1261
+ }, '‹'),
1262
+ h('span', { style: { minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, '话布文档'),
1263
+ h('span', {
1264
+ style: {
1265
+ marginLeft: 'auto', paddingRight: '4px', flex: 'none',
1266
+ fontSize: '12px', color: 'var(--dsw-alias-label-tertiary,#6b7280)',
1267
+ },
1268
+ }, (s.loading ? '读取中…' : (model.total + ' 篇')))),
1269
+ // ── 正文(官方 .body ✓ 内缩 8px,行自己带 5px 10px 内距)──
1270
+ h('div', {
1271
+ style: {
1272
+ flex: 'auto', minHeight: 0, overflow: 'auto',
1273
+ marginRight: '2px', padding: '8px 0 8px 8px',
1274
+ scrollbarGutter: 'stable',
1275
+ },
1276
+ },
1277
+ !sessionId ? h('div', {
1278
+ style: { padding: '3px 10px', fontSize: '12px', color: 'var(--dsw-alias-state-error-primary,#d33)' },
1279
+ }, '⚠ 这个标签页没拿到 sessionId,点了会打不开(已记日志)') : null,
1280
+ s.err ? h('div', {
1281
+ style: { padding: '3px 10px', fontSize: '12px', color: 'var(--dsw-alias-state-error-primary,#d33)' },
1282
+ }, '读清单失败:' + s.err) : null,
1283
+ // 官方 .note:没有内容时的说明行 ✓
1284
+ !s.loading && !model.total ? h('div', {
1285
+ style: { padding: '3px 10px', fontSize: '12px', color: 'var(--dsw-alias-label-tertiary,#6b7280)' },
1286
+ }, '还没有话布文档') : null,
1287
+ rows))
1288
+ }
1289
+ // ── 一级页:功能选页(照官方「开始」页的居中卡片布局)──
1290
+ var entries = [
1291
+ {
1292
+ icon: '📄',
1293
+ title: '话布文档',
1294
+ desc: 'markdown:可编辑 · 自动保存 · 撤销重做 · 划词问 AI',
1295
+ onClick: function () { go('docs') },
1296
+ },
1297
+ { icon: '🌐', title: '网页实时可视编辑', desc: '还没做', disabled: true },
1298
+ ]
1299
+ return ccvGuideColumn([
1300
+ // 英雄图标(官方 .hero 是那个罗盘 ✓ 这里用话布自己的图标 ✓)
1301
+ h('div', {
1302
+ style: {
1303
+ display: 'flex', marginBottom: '16px',
1304
+ color: 'var(--dsw-alias-label-secondary,#8b8b8b)',
1305
+ },
1306
+ }, h(CcvTabGlyph, { size: 26 })),
1307
+ // ⚠️ 2026-10-24 主人:「选一个功能这几个字不要」→ 那行说明删掉 ✗
1308
+ // 官方「开始」页本来也没有这行字 ✓ 删了反而更像 ✓
1309
+ ].concat(entries.map(function (e, i) {
1310
+ return h(CcvEntry, { key: 'e' + i, entry: e })
1311
+ })))
1465
1312
  }
1466
1313
 
1467
- function showDropdown(anchor, items, onSelect) {
1468
- var rect = anchor.getBoundingClientRect()
1469
- openDropdownAt(rect.left, rect.bottom + 4, items, onSelect)
1314
+ // ★ 注册用的包装组件:一律套错误边界 ✓(崩了也看得见原因 ✓)
1315
+ function CcvCanvasTab(props) {
1316
+ var body = h(CcvFeatureTab, props)
1317
+ return CcvErrorBoundary ? h(CcvErrorBoundary, null, body) : body
1470
1318
  }
1471
1319
 
1472
- // ─── 画布命令统一分发(划词栏 / ⋮ / 右键菜单共用)──────────
1473
- function escHtml(s) {
1474
- return String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;")
1475
- }
1476
- // 插入时用 host 接口地址,图/链接当场就能显示;htmlToMd 的 assetMdPath
1477
- // 存盘时再换回 attachments/ 相对路径,两边互逆
1478
- function assetDisplayUrl(p) {
1479
- if (/^attachments\//.test(p)) return "/api/canvas/file?name=" + encodeURIComponent(p)
1480
- return p
1481
- }
1482
- function selectionText() {
1483
- var sel = document.getSelection()
1484
- return sel ? sel.toString() : ""
1485
- }
1486
- function currentRange() {
1487
- var s = document.getSelection()
1488
- return (s && s.rangeCount) ? s.getRangeAt(0).cloneRange() : null
1489
- }
1490
- // 异步上传完成后在原光标处插 HTML:先聚焦编辑器、找回范围再 insertHTML
1491
- function insertHtmlAtRange(html, rng) {
1492
- var ed = editorRef.current
1493
- if (!ed) return
1494
- ed.focus()
1495
- if (rng) {
1496
- try { var s = document.getSelection(); s.removeAllRanges(); s.addRange(rng) } catch (_) {}
1497
- }
1498
- document.execCommand("insertHTML", false, html)
1499
- }
1500
- // 上传文件 → host /api/canvas/upload → 返回相对路径 attachments/xxx
1501
- function uploadCanvasFile(file, onOk, onFail) {
1502
- try {
1503
- fetch("/api/canvas/upload?name=" + encodeURIComponent(file.name || "file.bin"), {
1504
- method: "POST",
1505
- headers: { "Content-Type": file.type || "application/octet-stream" },
1506
- body: file
1507
- }).then(function(r) { return r.json() }).then(function(d) {
1508
- if (d && d.ok && d.path) onOk(d.path, file.name || "图片")
1509
- else onFail(file)
1510
- }).catch(function() { onFail(file) })
1511
- } catch (e) { onFail(file) }
1512
- }
1513
- // 上传失败兜底:图片转 dataURL 内嵌(md 会变大,仅作 fallback)
1514
- function fileToDataUrl(file, cb) {
1515
- try {
1516
- var fr = new FileReader()
1517
- fr.onload = function() { cb(String(fr.result)) }
1518
- fr.onerror = function() { cb("") }
1519
- fr.readAsDataURL(file)
1520
- } catch (_) { cb("") }
1521
- }
1522
- // 打开系统文件选择框,选中后回调(菜单点击是用户手势,input.click() 能弹出)
1523
- function pickFile(accept, cb) {
1524
- var inp = document.createElement("input")
1525
- inp.type = "file"
1526
- if (accept) inp.accept = accept
1527
- inp.style.display = "none"
1528
- document.body.appendChild(inp)
1529
- inp.addEventListener("change", function() {
1530
- var f = inp.files && inp.files[0]
1531
- if (f) cb(f)
1532
- inp.remove()
1533
- })
1534
- inp.click()
1535
- }
1536
- function insertTableHtml(rows, cols) {
1537
- var html = "<table>"
1538
- for (var r = 0; r < rows; r++) {
1539
- html += "<tr>"
1540
- for (var c = 0; c < cols; c++) html += (r === 0 ? "<th>表头</th>" : "<td></td>")
1541
- html += "</tr>"
1542
- }
1543
- html += "</table><div><br></div>"
1544
- document.execCommand("insertHTML", false, html)
1545
- }
1546
- // 引用来源的 @ 提及:算出当前话布对应的 md 真实路径。
1547
- // 为什么要带来源:用户引用一段文字问 AI 时,如果两篇文档含同样的文字,
1548
- // 光看引用块 AI 无从判断该改哪一篇 —— 只能靠记忆猜,猜错就改了错的文档,
1549
- // 而且是静默的(用户看不到任何变化)。带上 @路径 这一步就变成事实判断。
1550
- //
1551
- // ⚠️ 必须用 host 给的真实绝对路径,不能自己拼 "canvas-docs/<标题>.md":
1552
- // 那是相对路径,只在「会话工作区 == 存储根」时才成立。存储根被
1553
- // canvas_configure 固定到别处后(rootOverride),它指向的是工作区里
1554
- // 的残留目录 —— 文件根本不在那儿(2026-09-14 另一会话据此报过)。
1555
- //
1556
- // ⚠️ 真实路径几乎必然含空格("DSH project"、"OneDrive - …"),必须用
1557
- // DSH 的引号形式 @"path":@ token 由空白终止,裸写会在第一个空格处截断。
1558
- // 规则与 dsh-file-reference 的 formatFileMention 一致。
1559
- function ccvDocMention(id) {
1560
- var c = null
1561
- try {
1562
- (canvases || []).forEach(function (x) { if (x && x.id === id) c = x })
1563
- } catch (_) {}
1564
- if (!c) return ''
1565
- if (c.path) {
1566
- var abs = String(c.path).replace(/\\/g, '/')
1567
- return /\s/.test(abs) ? '@"' + abs + '"' : '@' + abs
1568
- }
1569
- if (!c.title) return ''
1570
- // 兜底:拿不到真实路径时(旧版 host 没返回 path 字段)退回相对形式
1571
- var slug = String(c.title).replace(/[\\/:*?"<>|#%]+/g, '').trim().replace(/\s+/g, '-')
1572
- if (!slug) slug = 'untitled'
1573
- return '@canvas-docs/' + slug + '.md'
1574
- }
1320
+ try {
1321
+ ctx.effect(function () {
1322
+ return ctx.sidebarRightTabs.register({
1323
+ id: CCV_TAB_ID,
1324
+ kind: CCV_TAB_ID,
1325
+ priority: 'extension',
1326
+ title: function () { return '话布' },
1327
+ // 门厅页(右侧栏「开始」)的入口胶囊 —— 官方「工作区文件」是 order 10
1328
+ guide: [{
1329
+ order: 20,
1330
+ title: function () { return '话布' },
1331
+ description: function () { return '话布:文档与将来的可视编辑功能,都从这里进'; },
1332
+ icon: CcvTabGlyph,
1333
+ }],
1334
+ })
1335
+ }, 'collab-canvas: 右侧栏 tab 类型')
1336
+ ccvLog('右侧栏「话布」标签类型已注册(id=' + CCV_TAB_ID + ')')
1337
+ } catch (e) { ccvLog('右侧栏 tab 类型注册失败: ' + (e && e.message)) }
1575
1338
 
1576
- // 问 AI:选中文本以引用块格式塞进 DSH 输入框(划词栏💬与右键菜单共用)
1577
- function askAiFromSelection() {
1578
- var sel = document.getSelection()
1579
- var text = sel ? sel.toString().trim() : ''
1580
- if (!text) { alert('请先选中要问 AI 的文本'); return }
1339
+ try {
1340
+ ctx.slots.inject('sidebar.right.pane.tab', function () {
1341
+ return ctx.slots.register({ name: 'sidebar.right.pane.tab', key: CCV_TAB_ID }, CcvCanvasTab)
1342
+ })
1343
+ ccvLog('右侧栏「话布」标签正文已注册')
1344
+ } catch (e) { ccvLog('右侧栏 tab 正文注册失败: ' + (e && e.message)) }
1581
1345
 
1582
- // 选区引用(侧模块 lib/selref.js)。两件事,顺序一步都不能动:
1583
- // ① 记一笔 —— 取数由它自己从 window.__ccvSelRefBridge 读。
1584
- // ⚠️ 必须赶在 hideSelbar / 输入框抢焦点之前:那之后浏览器选区就塌了,
1585
- // 侧模块再也取不到"用户到底划了哪一段"。
1586
- // ② 请它替用户走官方两步(打开菜单 → 选中)。成功的话输入框里直接出胶囊,
1587
- // 这时**不能再塞老引用块** —— 否则同一段话进两遍(用户会以为坏了)。
1588
- // 它没加载、或自动那步没成 → 一律退回老做法(下面的 insertQuoteBlock),
1589
- // 保证「问AI」永远有东西可用,不会点了没反应。
1590
- var rec = null
1591
- try { if (window.__ccvSelRefCapture) rec = window.__ccvSelRefCapture() } catch (_) {}
1592
- hideSelbar()
1593
1346
 
1594
- var input =
1595
- document.querySelector('div[data-composer-input="true"]') ||
1596
- document.querySelector('[data-slot="conversation.input"] textarea') ||
1597
- document.querySelector('[data-slot="conversation.composer"] textarea') ||
1598
- document.querySelector('[data-slot="conversation.composer.bar"] textarea') ||
1599
- document.querySelector('[data-pane="conversation"] textarea') ||
1600
- document.querySelector('textarea[placeholder*="发消息"]') ||
1601
- document.querySelector('textarea[placeholder*="说话"]') ||
1602
- document.querySelector('textarea[placeholder*="消息"]') ||
1603
- document.querySelector('textarea[placeholder*="输入"]') ||
1604
- document.querySelector('[contenteditable="true"][role="textbox"]')
1605
- // 先把焦点交回输入框再请侧模块动手:胶囊是插进输入框的,
1606
- // 且官方那套是照着"输入框当前状态"做版本比对的。
1607
- if (input) { try { input.focus() } catch (_) {} }
1347
+ // 区段内定义了 9 个顶层名字 ✓ apply 区段外只用到这一个 ✓
1348
+ return { CcvFeatureTab: CcvFeatureTab }
1349
+ }
1608
1350
 
1609
- var auto = null
1610
- if (rec) { try { if (window.__ccvSelRefAuto) auto = window.__ccvSelRefAuto(rec) } catch (_) {} }
1611
- if (auto && typeof auto.then === 'function') {
1612
- auto.then(function (ok) { if (!ok) insertQuoteBlock(input, text) })
1613
- .catch(function () { insertQuoteBlock(input, text) })
1614
- } else {
1615
- insertQuoteBlock(input, text)
1616
- }
1351
+ // ══════════════════════════════════════════════════════════════════════
1352
+ // 画布小工具集:地址拼装 / 打开文档 / 文件树行 / 入口卡片 / 错误边界
1353
+ // (2026-10-27 重构 P4b,逐字搬运)
1354
+ //
1355
+ // 机器核对过(不是目测):
1356
+ // 区段顶层定义 17 个;apply 区段外只用到其中 6 个 → 由返回值交回 ✓
1357
+ // 区段内只依赖 ctx(+ 模块级的 h / React / ccvLog / ccvLogic)✓
1358
+ //
1359
+ // 为什么放这里而不是拆成四个文件:
1360
+ // 侧模块不能 require ✗ 拆文件就得靠 window 交接 ✓ 反而更绕 ✓
1361
+ // 一个"无状态工具集"工厂已经足够清楚 ✓
1362
+ function ccvMakeCanvasKit(D) {
1363
+ var ctx = D.ctx
1364
+
1365
+ // ─── 文件路径 → dsh-resource 地址(2026-10-24 重写,照官方算法)───────
1366
+ // ⚠️ 上一版的做法是"等查看器打开过文档后把地址模板登记到 window"✗
1367
+ // → **先有鸡还是先有蛋** ✗ 没打开过文档就永远点不动 ✓
1368
+ // (日志实测:反复报"还没拿到 dsh-resource 地址模板"✓)
1369
+ //
1370
+ // ★ 正路:地址可以**自己拼** ✓ 官方源码里就有现成的算法 ★
1371
+ // 出处:@deepseek-ai/dsh-client-ui-sidebar-files/lib/client.js
1372
+ // FILE_ADDRESS_PREFIX = "dsh-resource://file/"
1373
+ // encodeSegment(s) = encodeURIComponent(s).replace(/%3A/gi, ":") ← 盘符冒号保留
1374
+ // encodePath(p) = p.split("/").map(encodeSegment).join("/")
1375
+ // sessionFileAddress(sid, p) =
1376
+ // PREFIX + "session/" + encodeSegment(sid) + "/" + encodePath(p)
1377
+ // fileAddressFor(sid, cwd, path):
1378
+ // 绝对路径且**在 workspace 根之内** → 转成【相对根】的路径 ✓
1379
+ // 否则(根未知 / 在根之外)→ **保留绝对路径** ✓
1380
+ //
1381
+ // ★ sessionId 从哪来:不用猜 ✓
1382
+ // 官方 FilesBody 的 props 就是 { useTabInfo, sessionId, useStore, actions, … }
1383
+ // —— 也就是说 `sidebar.right.pane.tab` 这个槽**本来就把 sessionId 传给组件** ✓
1384
+ // 我原来的 CcvFeatureTab 没接 props ✗ 所以拿不到 ✓(这是第二处疏漏 ✓)
1385
+ //
1386
+ // ⚠️ 反斜杠一律用 split/join ✓ 不用正则字面量 ✗(2026-10-22 就是这么崩的 ✓)
1387
+ // ⚠️ 2026-10-26:编码/拼装本体已搬进 lib/ccv-logic.js ✓ 这里只做委托
1388
+ // 好处:同一套算法有 60 条 Node 断言守着 ✓ 不再靠"看着像对的" ✓
1389
+ var CCV_FILE_PREFIX = 'dsh-resource://file/'
1390
+ function ccvSessionFileAddress(sessionId, absPath) {
1391
+ var L = ccvLogic()
1392
+ if (L) return L.sessionFileAddress(sessionId, absPath)
1393
+ // 逻辑层没到(极早期)→ 明确报错并返回空 ✓ 不静默拼错 ✗
1394
+ ccvLog('拼地址失败:逻辑层未加载 ✗')
1395
+ return ''
1617
1396
  }
1618
-
1619
- // 输入框里现有几条「引用胶囊」。
1620
- // ⚠️ 判断方式说明:官方把草稿以**纯文字投影**给出(胶囊摊成 `@名字 > 说明`),
1621
- // 所以光看草稿文字是分不出"胶囊"和"用户手打的 @"的,得看胶囊清单。
1622
- function ccvInputChipCount() {
1397
+ // 打开一篇话布文档 ✓(主人要的"点一下跳过去"✓)
1398
+ // ⚠️ 失败必须**说出来** ✗ 不能静默 ✓(这一轮就是静默才查了半天 ✓)
1399
+ function ccvOpenDoc(absPath, title, sessionId) {
1400
+ var t0 = Date.now()
1401
+ var p = String(absPath || '').trim()
1402
+ if (!p) { ccvLog('打开失败:这篇没有磁盘路径(还没落盘)✗'); return }
1403
+ var addr = ''
1623
1404
  try {
1624
- var st = window.__ccvInputState
1625
- return (st && st.occurrences) ? st.occurrences.length : 0
1626
- } catch (_) { return 0 }
1627
- }
1628
-
1629
- // 老做法:把选中文本以引用块格式塞进输入框。
1630
- // 只在「侧模块没加载」或「自动出胶囊没成功」时走这里 —— 它是兜底,不是主路。
1631
- //
1632
- // 🔴 2026-09-15 主人第二次报同一个毛病后立下的硬规矩:
1633
- // **草稿里已经有胶囊时,绝不许整体重写草稿。**
1634
- // 因为整体重写(setDraft)要按纯文字重新填一遍,而纯文字里的胶囊
1635
- // 只剩 `@名字 > 说明` 这种样子 —— 重写一次就把主人前面所有引用
1636
- // 拍成一堆普通文字,而且**界面上毫无异常提示**。
1637
- // 所以顺序是:① 官方事件往末尾追加(不动已有内容,首选)
1638
- // ② 一个胶囊都没有 → 才允许整体重写
1639
- // ③ 有胶囊又插不进去 → 什么都不做,只告诉主人一声
1640
- function insertQuoteBlock(input, text) {
1641
- // ⚠️ 必须留一个空格:@ 提及 token 由空白字符终止,
1642
- // 写成 "@xxx>内容" 会把 ">" 并进路径里。
1643
- var mention = ccvDocMention(activeId)
1644
- // 多行选区每一行都要带 ">",否则只有首行是引用块
1645
- var quoted = text.split('\n').map(function (l) { return '> ' + l }).join('\n')
1646
- var body = (mention ? mention + ' ' : '') + quoted
1647
- if (!input) return
1648
- if (input.tagName === 'TEXTAREA') {
1649
- var quote = body + '\n\n'
1650
- var nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set
1651
- nativeInputValueSetter.call(input, quote + input.value)
1652
- input.dispatchEvent(new Event('input', { bubbles: true }))
1653
- input.focus()
1405
+ addr = ccvSessionFileAddress(sessionId, p)
1406
+ } catch (e) { ccvLog('拼地址出错: ' + (e && e.message)) }
1407
+ if (!addr) {
1408
+ ccvLog('打开失败:拿不到 sessionId ✗(这个标签页的 props.sessionId 是空的 ✓)')
1654
1409
  return
1655
1410
  }
1656
-
1657
- // ① 首选:请侧模块走官方「插入纯文字」事件 —— 零宽、只往末尾追加,
1658
- // 已插进去的胶囊一个都不动。
1411
+ // ★ 先把真实地址打出来 ✓ 出问题一眼能对照官方的编码形式 ★
1412
+ ccvLog('准备打开:' + (title || '') + ' | session=' + String(sessionId).slice(0, 14)
1413
+ + '… | 地址=' + addr)
1659
1414
  try {
1660
- if (window.__ccvSelRefText && window.__ccvSelRefText(body + '\n\n')) {
1661
- input.focus()
1662
- return
1663
- }
1664
- } catch (_) {}
1665
-
1666
- // ② 整体重写只允许在没有胶囊时用(此时重写等于"填一份纯文字",无害)
1667
- if (ccvInputChipCount() > 0) {
1668
- alert('这条引用没能插进输入框。\n\n输入框里已经有引用胶囊了,而唯一还能用的办法是'
1669
- + '把整段草稿按文字重写一遍 —— 那样会把已有的引用变成普通文字。'
1670
- + '所以这次没有插入,请再点一次「问AI」。')
1671
- return
1672
- }
1673
-
1674
- // ③ 空草稿:整体重写(能保留换行)
1675
- var draftState = window.__ccvInputState
1676
- var curDraft = (draftState && draftState.draft) ? draftState.draft : ''
1677
- var newDraft = body + '\n\n' + curDraft
1678
- if (window.__ccvInputActions && window.__ccvInputActions.setDraft) {
1679
- window.__ccvInputActions.setDraft(newDraft)
1680
- } else {
1681
- // fallback: 直接写 DOM + 派发 input
1682
- var escTxt = String(text).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
1683
- var escMention = mention ? String(mention).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;') + ' ' : ''
1684
- var quoteHtml = '<div>' + escMention + '&gt; ' + escTxt + '</div><div><br></div><div><br></div>'
1685
- input.innerHTML = quoteHtml + input.innerHTML
1686
- input.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: body }))
1687
- }
1688
- input.focus()
1689
- }
1690
- function runCanvasCmd(cmd) {
1691
- restoreSelection()
1692
- var text
1693
- switch (cmd) {
1694
- case "highlight": {
1695
- text = selectionText()
1696
- if (!text) return true
1697
- document.execCommand("insertHTML", false, "<mark>" + escHtml(text) + "</mark>")
1698
- break
1699
- }
1700
- case "clearFormat": {
1701
- document.execCommand("removeFormat", false, null)
1702
- // removeFormat 管不到自定义的 <mark> 和 <a>:选区内的手动拆掉。
1703
- // 脚注引用 [1](href="#fn-…")是内容标记不是格式,保留
1704
- try {
1705
- var ed3 = editorRef.current
1706
- var s3 = document.getSelection()
1707
- if (ed3 && s3 && s3.rangeCount) {
1708
- var rg = s3.getRangeAt(0)
1709
- var marks = ed3.querySelectorAll("mark")
1710
- for (var mi = marks.length - 1; mi >= 0; mi--) {
1711
- if (rg.intersectsNode(marks[mi])) {
1712
- var pMark = marks[mi].parentNode
1713
- while (marks[mi].firstChild) pMark.insertBefore(marks[mi].firstChild, marks[mi])
1714
- pMark.removeChild(marks[mi])
1715
- }
1716
- }
1717
- var links = ed3.querySelectorAll("a")
1718
- for (var ai = links.length - 1; ai >= 0; ai--) {
1719
- var lh = links[ai].getAttribute("href") || ""
1720
- if (lh.indexOf("#fn") === 0) continue
1721
- if (rg.intersectsNode(links[ai])) {
1722
- var pA = links[ai].parentNode
1723
- while (links[ai].firstChild) pA.insertBefore(links[ai].firstChild, links[ai])
1724
- pA.removeChild(links[ai])
1725
- }
1726
- }
1727
- }
1728
- } catch (_) {}
1729
- break
1730
- }
1731
- case "inlineCode": {
1732
- text = selectionText() || "code"
1733
- document.execCommand("insertHTML", false, '<code style="background:rgba(136,136,136,.15);padding:1px 4px;border-radius:3px;font-family:monospace;font-size:.9em">' + escHtml(text) + '</code>')
1734
- break
1735
- }
1736
- case "codeBlock": {
1737
- text = selectionText() || "code"
1738
- document.execCommand("insertHTML", false, '<pre style="background:#f5f5f5;padding:12px 16px;border-radius:6px;font-family:monospace;font-size:13px;line-height:1.5;overflow-x:auto;white-space:pre"><code>' + escHtml(text) + '</code></pre><div><br></div>')
1739
- break
1740
- }
1741
- case "blockquote": {
1742
- text = selectionText() || "引用内容"
1743
- document.execCommand("insertHTML", false, '<blockquote style="border-left:3px solid #ccc;padding-left:12px;margin:8px 0;color:#666">' + escHtml(text) + '</blockquote><div><br></div>')
1744
- break
1745
- }
1746
- case "link": {
1747
- var url = prompt("请输入链接地址:", "https://")
1748
- if (url) {
1749
- text = selectionText().trim() || "链接文字"
1750
- document.execCommand("insertHTML", false, '<a href="' + escHtml(url) + '" style="color:#3b82f6;text-decoration:underline">' + escHtml(text) + '</a>')
1751
- }
1752
- break
1753
- }
1754
- case "footnote": {
1755
- // 脚注:自动生成编号,插入引用标记,并在文末添加定义
1756
- var label = prompt("脚注标签(如 1、2、a):", "")
1757
- if (label) {
1758
- var html = '<a href="#fn-' + escHtml(label) + '" id="fnref-' + escHtml(label) + '" contenteditable="false" style="vertical-align:super;font-size:.75em;color:#3b82f6;cursor:pointer;text-decoration:none">[' + escHtml(label) + ']</a>'
1759
- document.execCommand("insertHTML", false, html)
1760
- var ed = editorRef.current
1761
- if (ed) {
1762
- var existing = ed.querySelector('#fn-' + label)
1763
- if (!existing) {
1764
- var fnDiv = ed.querySelector('.footnotes')
1765
- if (!fnDiv) {
1766
- fnDiv = document.createElement('div')
1767
- fnDiv.className = 'footnotes'
1768
- fnDiv.innerHTML = '<hr/>'
1769
- var ol = document.createElement('ol')
1770
- fnDiv.appendChild(ol)
1771
- ed.appendChild(fnDiv)
1772
- }
1773
- var ol2 = fnDiv.querySelector('ol')
1774
- var li = document.createElement('li')
1775
- li.id = 'fn-' + label
1776
- li.innerHTML = '脚注 ' + escHtml(label) + ' 的内容 <a href="#fnref-' + escHtml(label) + '" contenteditable="false" style="color:#3b82f6;text-decoration:none">↩</a>'
1777
- ol2.appendChild(li)
1778
- li.scrollIntoView({ behavior: 'smooth', block: 'center' })
1779
- }
1780
- }
1781
- }
1782
- break
1783
- }
1784
- case "uploadImage": {
1785
- var rngImg = currentRange()
1786
- pickFile("image/*", function(f) {
1787
- uploadCanvasFile(f, function(p, name) {
1788
- insertHtmlAtRange('<img src="' + escHtml(assetDisplayUrl(p)) + '" alt="' + escHtml(name) + '" style="max-width:100%;height:auto;vertical-align:middle"/>', rngImg)
1789
- }, function(f2) {
1790
- fileToDataUrl(f2, function(u) {
1791
- if (u) insertHtmlAtRange('<img src="' + u + '" alt="" style="max-width:100%;height:auto;vertical-align:middle"/>', rngImg)
1792
- else alert("图片上传失败")
1793
- })
1794
- })
1795
- })
1796
- break
1797
- }
1798
- case "imageUrl": {
1799
- var iurl = prompt("请输入图片地址:", "https://")
1800
- if (iurl) document.execCommand("insertHTML", false, '<img src="' + escHtml(iurl) + '" alt="" style="max-width:100%;height:auto;vertical-align:middle"/>')
1801
- break
1802
- }
1803
- case "uploadFile": {
1804
- var rngFile = currentRange()
1805
- pickFile("", function(f) {
1806
- uploadCanvasFile(f, function(p, name) {
1807
- insertHtmlAtRange('<a href="' + escHtml(assetDisplayUrl(p)) + '">' + escHtml(name) + '</a>', rngFile)
1808
- }, function() { alert("文件上传失败") })
1809
- })
1810
- break
1811
- }
1812
- case "insertTable": {
1813
- var rc = prompt("表格行数×列数(如 3×4):", "3×3")
1814
- if (rc) {
1815
- var mm = rc.match(/(\d+)\s*[×xX*]\s*(\d+)/)
1816
- if (mm) insertTableHtml(Math.max(1, Math.min(50, +mm[1])), Math.max(1, Math.min(20, +mm[2])))
1817
- else alert("格式不对,示例:3×4")
1415
+ ctx.sidebarRight.openResource(addr)
1416
+ ccvLog('已请求打开文档 ✓ ' + (title || '') + ' | 调用耗时 ' + (Date.now() - t0) + 'ms')
1417
+ } catch (e) {
1418
+ // 官方注释:地址不合法 / 没人认领 → 抛异常(那是接线错误)
1419
+ ccvLog('打开文档失败 ✗ ' + ((e && e.message) || e) + ' | 地址=' + addr)
1420
+ try {
1421
+ if (window.__ccvKit && window.__ccvKit.toast) {
1422
+ window.__ccvKit.toast('打开失败:' + ((e && e.message) || '地址没被认领'), 'ccv-open-doc')
1818
1423
  }
1819
- break
1820
- }
1821
- case "insertTaskList": {
1822
- document.execCommand("insertHTML", false, '<ul><li><input type="checkbox"> 任务事项</li></ul><div><br></div>')
1823
- break
1824
- }
1825
- case "highlightBlock": {
1826
- text = selectionText()
1827
- var inner = text ? escHtml(text) : "高亮内容"
1828
- document.execCommand("insertHTML", false, '<div class="ccv-hl"><p>' + inner + '</p></div><div><br></div>')
1829
- break
1830
- }
1831
- case "indent": document.execCommand("indent", false, null); break
1832
- case "outdent": document.execCommand("outdent", false, null); break
1833
- default: return false
1424
+ } catch (_) {}
1834
1425
  }
1835
- if (editorRef.current) editorRef.current.focus()
1836
- return true
1837
1426
  }
1838
- // ─── 右键菜单(桌面端插入入口)──────────────────────
1839
- function showContextMenu(x, y) {
1840
- var hasSel = selectionText().trim().length > 0
1841
- var items = [
1842
- { label: "问AI", cmd: "askAI", icon: ccvIcon("ask") },
1843
- { type: "sep" },
1844
- { type: "header", label: "常用" },
1845
- { label: "上传图片…", cmd: "uploadImage", icon: ccvIcon("uploadImage") },
1846
- { label: "网络图片…", cmd: "imageUrl", icon: ccvIcon("globe") },
1847
- { label: "上传文件…", cmd: "uploadFile", icon: ccvIcon("file") },
1848
- { label: "插入表格…", cmd: "insertTable", icon: ccvIcon("table") },
1849
- { label: "插入链接…", cmd: "link", icon: ccvIcon("link") },
1850
- { type: "row", items: [
1851
- { label: "无序列表", cmd: "insertUnorderedList", icon: ccvIcon("listUl") },
1852
- { label: "有序列表", cmd: "insertOrderedList", icon: ccvIcon("listOl") },
1853
- { label: "任务列表", cmd: "insertTaskList", icon: ccvIcon("task") },
1854
- ]},
1855
- { type: "sep" },
1856
- { type: "header", label: "块" },
1857
- { type: "row", items: [
1858
- { label: "代码块", cmd: "codeBlock", icon: ccvIcon("codeBlock") },
1859
- { label: "引用", cmd: "blockquote", icon: ccvIcon("quote") },
1860
- { label: "分割线", cmd: "insertHorizontalRule", icon: ccvIcon("hr") },
1861
- { label: "高亮块", cmd: "highlightBlock", icon: ccvIcon("hlBlock") },
1862
- ]},
1863
- { type: "sep" },
1864
- { label: "增加缩进", cmd: "indent", icon: ccvIcon("indent") },
1865
- { label: "减少缩进", cmd: "outdent", icon: ccvIcon("outdent") },
1866
- ]
1867
- if (hasSel) {
1868
- items.push(
1869
- { type: "sep" },
1870
- { type: "header", label: "转换为" },
1871
- { label: "代码块", cmd: "codeBlock", icon: ccvIcon("codeBlock") },
1872
- { label: "引用", cmd: "blockquote", icon: ccvIcon("quote") },
1873
- { label: "高亮", cmd: "highlight", icon: ccvIcon("highlight") },
1874
- { label: "链接", cmd: "link", icon: ccvIcon("link") }
1875
- )
1876
- }
1877
- openDropdownAt(x, y, items, function(item) {
1878
- if (item.cmd === "askAI") { askAiFromSelection(); return }
1879
- if (!runCanvasCmd(item.cmd)) document.execCommand(item.cmd, false, item.val)
1880
- })
1427
+ function CcvTabGlyph(props) {
1428
+ var size = (props && props.size) ? props.size : 16
1429
+ return h('svg', {
1430
+ width: size, height: size, viewBox: '0 0 24 24', fill: 'none',
1431
+ stroke: 'currentColor', strokeWidth: 1.6,
1432
+ strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': 'true',
1433
+ },
1434
+ h('rect', { x: 3, y: 4, width: 18, height: 16, rx: 3 }),
1435
+ h('path', { d: 'M7.5 9.5h9M7.5 13h6' }))
1881
1436
  }
1882
-
1883
- // 标题下拉菜单
1884
- function showHeadingDropdown(anchor) {
1885
- var currentH = selectionFormats.heading
1886
- var items = [
1887
- { label: "正文", val: "<p>", active: !currentH },
1888
- { type: "sep" },
1889
- { label: "一级标题", val: "<h1>", active: currentH === 'h1', style: "font-size:18px;font-weight:700" },
1890
- { label: "二级标题", val: "<h2>", active: currentH === 'h2', style: "font-size:16px;font-weight:600" },
1891
- { label: "三级标题", val: "<h3>", active: currentH === 'h3', style: "font-size:14px;font-weight:600" },
1892
- { label: "四级标题", val: "<h4>", active: currentH === 'h4', style: "font-size:13px;font-weight:600" },
1893
- { label: "五级标题", val: "<h5>", active: currentH === 'h5', style: "font-size:12px;font-weight:500" },
1894
- { label: "六级标题", val: "<h6>", active: currentH === 'h6', style: "font-size:11px;font-weight:500" },
1895
- ]
1896
- showDropdown(anchor, items, function(item) {
1897
- restoreSelection()
1898
- document.execCommand('formatBlock', false, item.val)
1899
- if (editorRef.current) editorRef.current.focus()
1900
- })
1437
+ // ★ 清单每一行 = **独立子组件** ✓(2026-10-23 修空白)★
1438
+ // ⚠️ 原来把 React.useState 写在了 .map() 回调里 ✗✗
1439
+ // React 铁律:**Hooks 只能在组件顶层调用** ✗ 不能在循环/回调/条件里 ✓
1440
+ // → 清单条数一变,hook 数量就变 ✗
1441
+ // → 报「Rendered more hooks than during the previous render」✗
1442
+ // → **这个标签页整个崩掉 → 只剩空白** ✓✓
1443
+ // 做法:抽成子组件 → 每个子组件自己管自己的 hover ✓
1444
+ // 它也是官方 dsh-client-ui-sidebar-files 的做法 ✓(树节点都是独立组件 ✓)
1445
+ // ★ 文件图标(16px,照官方文件树用 FileTypeIcon 的尺寸/颜色)★
1446
+ // 官方行里是 primitives 的 FileTypeIcon(size 16) + className fileIcon ✓
1447
+ // 我们的侧模块拿不到 primitives ✗ 所以画一个同风格的"文档纸"图标 ✓
1448
+ function CcvFileGlyph(props) {
1449
+ var size = (props && props.size) || 16
1450
+ return h('svg', {
1451
+ width: size, height: size, viewBox: '0 0 24 24', fill: 'none',
1452
+ stroke: 'currentColor', strokeWidth: 1.6,
1453
+ strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': 'true',
1454
+ },
1455
+ h('path', { d: 'M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z' }),
1456
+ h('path', { d: 'M14 3v5h5' }))
1901
1457
  }
1902
-
1903
- // 对齐下拉菜单
1904
- function showAlignDropdown(anchor) {
1905
- var items = [
1906
- { label: "左对齐", cmd: "justifyLeft", active: false },
1907
- { label: "居中对齐", cmd: "justifyCenter", active: false },
1908
- { label: "右对齐", cmd: "justifyRight", active: false },
1909
- ]
1910
- showDropdown(anchor, items, function(item) {
1911
- restoreSelection()
1912
- document.execCommand(item.cmd, false, null)
1913
- if (editorRef.current) editorRef.current.focus()
1914
- })
1458
+ // ★ 清单每一行 = 官方文件树的行样式(2026-10-24 主人要求)★
1459
+ // 出处:sidebar-files/lib/client.js 的 FilesBody.module.css ✓ 逐条照抄:
1460
+ // .row width:100%; min-width:0; color:inherit; font:inherit; text-align:left;
1461
+ // border-radius:var(--dsw-radius-md); cursor:pointer;
1462
+ // background:0 0; border:0; align-items:center; gap:6px;
1463
+ // padding:5px 10px; display:flex
1464
+ // .row:hover background:var(--dsw-alias-interactive-bg-hover)
1465
+ // .icon color:var(--dsw-alias-label-tertiary); flex:none ← 16px svg
1466
+ // .name white-space:nowrap; text-overflow:ellipsis; min-width:0; overflow:hidden
1467
+ // ⚠️ **不是卡片** ✗ —— 没有边框、没有圆角边框、没有第二行路径 ✓
1468
+ // 路径挪到 title(悬停才看)✓ 与文件树一致 ✓
1469
+ var CCV_ROW_BASE = {
1470
+ width: '100%', minWidth: 0,
1471
+ display: 'flex', alignItems: 'center', gap: '6px',
1472
+ padding: '5px 10px', textAlign: 'left',
1473
+ font: 'inherit',
1474
+ color: 'inherit',
1475
+ background: '0 0', border: 0,
1476
+ borderRadius: 'var(--dsw-radius-md,6px)',
1477
+ boxSizing: 'border-box',
1915
1478
  }
1916
-
1917
- // ↑ 已提到模块级:见文件上方「静态资源(模块级)」
1918
- // ─── 全功能菜单(⋮:移动端唯一入口,格式+插入都在)──────────────
1919
- function showFullMenu(anchor) {
1920
- var items = [
1921
- { type: "header", label: "格式" },
1922
- { label: "行内代码", cmd: "inlineCode", icon: ccvIcon("code") },
1923
- { label: "高亮", cmd: "highlight", icon: ccvIcon("highlight") },
1924
- { label: "清除格式", cmd: "clearFormat", icon: ccvIcon("clear") },
1925
- { label: "脚注", cmd: "footnote", icon: "¹" },
1926
- { type: "header", label: "插入" },
1927
- { label: "上传图片…", cmd: "uploadImage", icon: ccvIcon("uploadImage") },
1928
- { label: "网络图片…", cmd: "imageUrl", icon: ccvIcon("globe") },
1929
- { label: "上传文件…", cmd: "uploadFile", icon: ccvIcon("file") },
1930
- { label: "插入表格…", cmd: "insertTable", icon: ccvIcon("table") },
1931
- { label: "任务列表", cmd: "insertTaskList", icon: ccvIcon("task") },
1932
- { label: "无序列表", cmd: "insertUnorderedList", icon: ccvIcon("listUl") },
1933
- { label: "有序列表", cmd: "insertOrderedList", icon: ccvIcon("listOl") },
1934
- { label: "插入链接…", cmd: "link", icon: ccvIcon("link") },
1935
- { type: "header", label: "块" },
1936
- { type: "row", items: [
1937
- { label: "代码块", cmd: "codeBlock", icon: ccvIcon("codeBlock") },
1938
- { label: "引用", cmd: "blockquote", icon: ccvIcon("quote") },
1939
- { label: "分割线", cmd: "insertHorizontalRule", icon: ccvIcon("hr") },
1940
- { label: "高亮块", cmd: "highlightBlock", icon: ccvIcon("hlBlock") },
1941
- ]},
1942
- { type: "header", label: "缩进" },
1943
- { label: "增加缩进", cmd: "indent", icon: ccvIcon("indent") },
1944
- { label: "减少缩进", cmd: "outdent", icon: ccvIcon("outdent") },
1945
- ]
1946
- showDropdown(anchor, items, function(item) {
1947
- if (!runCanvasCmd(item.cmd)) document.execCommand(item.cmd, false, item.val)
1948
- })
1479
+ function CcvRow(props) {
1480
+ var c = props.canvas || {}
1481
+ var sessionId = props.sessionId || ''
1482
+ // ⚠️ 清单里那个字段叫 **filePath** ✓ 不是 path ✗
1483
+ // (host 的 canvas.list 原来只回 hasFile ✗ → 前端根本拿不到路径 ✓
1484
+ // 2026-10-22 一并补上了 filePath ✓ 见 src/host/06-rpc.js ✓)
1485
+ var fp = c.filePath || c.path || ''
1486
+ var canOpen = !!String(fp).trim()
1487
+ var hover = React.useState(false) // ← 组件顶层 ✓ 合法 ✓
1488
+ var hv = hover[0], setHv = hover[1]
1489
+ return h('div', {
1490
+ onClick: canOpen ? function () { ccvOpenDoc(fp, c.title, sessionId) } : undefined,
1491
+ onMouseEnter: canOpen ? function () { setHv(true) } : undefined,
1492
+ onMouseLeave: canOpen ? function () { setHv(false) } : undefined,
1493
+ title: canOpen ? ('打开:' + fp + '\n(可以拖到别的分组里)') : '(还没落盘,打不开)',
1494
+ role: 'button',
1495
+ // ★ 可拖动(2026-10-24 主人要「自由拖动改分组」)★
1496
+ // 只带 id ✓ 主机端按 id 改 .sessions/*.json 的 id 数组 ✓ 文件不动 ✓
1497
+ draggable: canOpen ? 'true' : 'false',
1498
+ onDragStart: canOpen ? function (e) {
1499
+ try {
1500
+ e.dataTransfer.setData('text/plain', c.id)
1501
+ e.dataTransfer.effectAllowed = 'move'
1502
+ e.currentTarget.style.opacity = '0.5'
1503
+ } catch (_) {}
1504
+ } : undefined,
1505
+ onDragEnd: canOpen ? function (e) {
1506
+ try { e.currentTarget.style.opacity = '1' } catch (_) {}
1507
+ } : undefined,
1508
+ style: Object.assign({}, CCV_ROW_BASE, {
1509
+ cursor: canOpen ? 'pointer' : 'default',
1510
+ opacity: canOpen ? 1 : 0.55,
1511
+ background: (canOpen && hv) ? 'var(--dsw-alias-interactive-bg-hover,var(--dsw-alias-bg-layer-2,rgba(128,128,128,.12)))' : '0 0',
1512
+ }),
1513
+ },
1514
+ h('span', { style: { display: 'flex', flex: 'none', color: 'var(--dsw-alias-label-tertiary,#6b7280)' } },
1515
+ h(CcvFileGlyph, { size: 16 })),
1516
+ h('span', {
1517
+ style: {
1518
+ whiteSpace: 'nowrap', textOverflow: 'ellipsis',
1519
+ minWidth: 0, overflow: 'hidden',
1520
+ },
1521
+ }, c.title || c.id))
1949
1522
  }
1950
- var selectionFormats = { bold: false, italic: false, underline: false, heading: null }
1951
-
1952
- function onEditorMouseUp() {
1953
- try {
1954
- ccvLogD("mouseup: enter")
1955
- var sel = document.getSelection()
1956
- if (!sel || sel.rangeCount === 0 || sel.isCollapsed) {
1957
- ccvLogD(function () { return "mouseup: 选区为空 -> hide (sel=" + (sel ? "collapsed" : "null") + ")" })
1958
- hideSelbar(); return
1959
- }
1960
- var rect = sel.getRangeAt(0).getBoundingClientRect()
1961
- ccvLogD(function () { return "mouseup: rect w=" + Math.round(rect.width) + " h=" + Math.round(rect.height) + " l=" + Math.round(rect.left) + " t=" + Math.round(rect.top) })
1962
- if (!rect || (!rect.width && !rect.height)) {
1963
- ccvLogD("mouseup: rect 空 -> hide")
1964
- hideSelbar(); return
1965
- }
1966
- updateSelFormats(sel)
1967
- showSelbar(rect)
1968
- } catch(err) { ccvLog("mouseup: 抛异常 " + (err && err.message)); hideSelbar() }
1523
+ // ★ 视觉风格 = 照抄官方「开始」页(2026-10-24 主人要求)★
1524
+ // 主人原话:「话布选功能的页面风格跟开始页保持一致」
1525
+ // 出处:@deepseek-ai/dsh-client-ui-sidebar-right/lib/client.js 里的
1526
+ // GuideBody.module.css —— 我把它的关键值原样搬过来 ✓
1527
+ // · 容器:flex 纵向 **居中**(justify-content:center / align-items:center)
1528
+ // gap 14px / min-height 100% / padding 0 24px
1529
+ // 末尾再来一个 **flex:0 10% 的占位**(官方 .guide:after ✓ 视觉重心略上)
1530
+ // · 卡片:宽 **380px**(max-width 100%)/ min-height 56px / padding 14px 20px
1531
+ // display:flex / align-items:center / gap:14px / text-align:left
1532
+ // background: var(--dsw-alias-bg-layer-1)
1533
+ // border: **.5px** solid var(--dsw-alias-border-l3) ← 半像素是官方的做法
1534
+ // border-radius: var(--dsw-radius-xl)
1535
+ // hover → var(--dsw-alias-interactive-bg-hover)
1536
+ // · 图标:26×26 / flex:none / color var(--dsw-alias-label-secondary)
1537
+ // · 标题:**14px** / line-height 1.4 / 不换行 + 省略号
1538
+ // · 描述:**11px** / 不换行 + 省略号 / color var(--dsw-alias-label-tertiary)
1539
+ // ⚠️ 这几个 token(border-l3 / label-tertiary / interactive-bg-hover /
1540
+ // radius-xl / static-neutral-*)**不在主题目录**里 ✓
1541
+ // 但官方自己就在用 ✓ 截图里也渲染正常 ✓
1542
+ // → 照用,但都带兜底值 ✓(拿不到也不会变成透明/黑色 ✗)
1543
+ var CCV_GUIDE_MAXW = 380
1544
+ var CCV_ENTRY_BASE = {
1545
+ boxSizing: 'border-box',
1546
+ width: CCV_GUIDE_MAXW + 'px',
1547
+ maxWidth: '100%',
1548
+ minHeight: '56px',
1549
+ display: 'flex',
1550
+ alignItems: 'center',
1551
+ gap: '14px',
1552
+ padding: '14px 20px',
1553
+ textAlign: 'left',
1554
+ // font: inherit(官方写法)→ 这里显式给一遍,免得被默认 UA 字体覆盖
1555
+ fontFamily: 'inherit',
1556
+ color: 'var(--dsw-alias-label-primary,#1a1a1a)',
1557
+ background: 'var(--dsw-alias-bg-layer-1,rgba(255,255,255,.04))',
1558
+ border: '.5px solid var(--dsw-alias-border-l3,rgba(128,128,128,.28))',
1559
+ borderRadius: 'var(--dsw-radius-xl,12px)',
1969
1560
  }
1970
-
1971
- function updateSelFormats(sel) {
1972
- selectionFormats.bold = document.queryCommandState('bold')
1973
- selectionFormats.italic = document.queryCommandState('italic')
1974
- selectionFormats.underline = document.queryCommandState('underline')
1975
- selectionFormats.heading = null
1976
- try {
1977
- var node = sel.getRangeAt(0).startContainer
1978
- if (node.nodeType === 3) node = node.parentNode
1979
- while (node && node.nodeType === 1) {
1980
- var tag = node.tagName ? node.tagName.toLowerCase() : ''
1981
- if (['h1','h2','h3','h4','h5','h6'].indexOf(tag) >= 0) { selectionFormats.heading = tag; break }
1982
- if (node.classList && node.classList.contains('ccv-editor')) break
1983
- node = node.parentNode
1984
- }
1985
- } catch(_){}
1561
+ var CCV_ENTRY_ICON = {
1562
+ width: '26px', height: '26px', flex: 'none',
1563
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
1564
+ color: 'var(--dsw-alias-label-secondary,#8b8b8b)',
1986
1565
  }
1987
-
1988
- function showSelbar(rect) {
1989
- var bar = document.getElementById('ccv-selbar')
1990
- if (!bar) { ccvLog('showSelbar: 找不到 #ccv-selbar 元素'); return }
1991
- // 先用「自然宽度」渲染(子项不收缩、文字不换行),渲染完再按实测尺寸定位。
1992
- // 顺序很关键:若先定位再渲染,栏会被 left 挤到只剩很窄的可用宽度,
1993
- // 按钮随之收缩,「💬 问AI」这类带文字的按钮内部就会折行。
1994
- bar.style.left = '0px'; bar.style.top = '0px'
1995
- bar.style.display = 'flex'
1996
- bar.innerHTML = ''
1997
- SELBAR_ITEMS.forEach(function(item) {
1998
- if (item.type === 'sep') {
1999
- var sep = document.createElement('span')
2000
- sep.style.cssText = 'width:1px;height:20px;background:rgba(128,128,128,.2);margin:0 2px;flex:0 0 auto'
2001
- bar.appendChild(sep)
2002
- return
2003
- }
2004
- var btn = document.createElement('button')
2005
- btn.innerHTML = item.label; btn.title = item.title
2006
- btn.style.cssText = 'padding:4px 8px;border:none;background:transparent;color:inherit;border-radius:6px;font-size:13px;cursor:pointer;font-family:inherit;display:flex;align-items:center;justify-content:center;min-width:28px;height:28px;transition:background .1s ease;flex:0 0 auto;white-space:nowrap;line-height:1'
2007
- btn.addEventListener('mouseenter', function() { if (!btn.dataset.active) btn.style.background = 'rgba(128,128,128,.12)' })
2008
- btn.addEventListener('mouseleave', function() { if (!btn.dataset.active) btn.style.background = 'transparent' })
2009
- var isActive = false
2010
- if (item.cmd === 'bold') isActive = selectionFormats.bold
2011
- else if (item.cmd === 'italic') isActive = selectionFormats.italic
2012
- else if (item.cmd === 'underline') isActive = selectionFormats.underline
2013
- else if (item.cmd === 'formatBlock' && item.val) isActive = selectionFormats.heading === item.val.replace('<','').replace('>','')
2014
- // 选中态:灰色胶囊(不遮字、深浅主题都可读),替代之前的黑白反色
2015
- if (isActive) { btn.dataset.active = '1'; btn.style.background = 'rgba(128,128,128,.18)'; btn.style.color = 'var(--dsw-alias-label-primary, #1a1a1a)' }
2016
- btn.addEventListener('mousedown', function(e) { e.preventDefault() })
2017
- btn.addEventListener('click', function(e) {
2018
- e.stopPropagation()
2019
- if (item.isAskAI) {
2020
- // 问 AI:选中文本以引用格式塞进输入框(右键菜单同款逻辑)
2021
- askAiFromSelection()
2022
- } else if (item.hasDropdown) {
2023
- if (item.isHeading) showHeadingDropdown(btn)
2024
- else if (item.showAlign) showAlignDropdown(btn)
2025
- else if (item.showFullMenu) showFullMenu(btn)
2026
- } else if (!runCanvasCmd(item.cmd)) {
2027
- document.execCommand(item.cmd, false, item.val)
2028
- if (editorRef.current) editorRef.current.focus()
2029
- }
2030
- })
2031
- bar.appendChild(btn)
2032
- })
2033
- // 渲染完再测量真实尺寸并定位:优先贴选区左侧,
2034
- // 右边放不下就整体左移(允许盖到会话区上方,即「超出话布」)。
2035
- var bw = bar.offsetWidth || 340
2036
- var bh = bar.offsetHeight || 40
2037
- var x = Math.max(8, Math.min(rect.left, window.innerWidth - bw - 8))
2038
- var y = rect.top - bh - 10
2039
- if (y < 8) y = Math.min(rect.bottom + 10, window.innerHeight - bh - 8)
2040
- bar.style.left = x + 'px'; bar.style.top = y + 'px'
2041
- // 落定后再量一次:位置/尺寸/裁剪祖先,任何一项不对都能从日志看出来
2042
- ccvLogD(function () {
2043
- var fin = bar.getBoundingClientRect()
2044
- return 'showSelbar: 完成 bw=' + bw + ' bh=' + bh + ' x=' + x + ' y=' + y +
2045
- ' | display=' + bar.style.display +
2046
- ' | 实际渲染 w=' + Math.round(fin.width) + ' h=' + Math.round(fin.height) +
2047
- ' l=' + Math.round(fin.left) + ' t=' + Math.round(fin.top) +
2048
- ' | offsetParent=' + ccvDesc(bar.offsetParent) +
2049
- ' | parent=' + ccvDesc(bar.parentElement)
2050
- })
2051
- // 沿祖先链找第一个会裁剪 fixed 子元素的容器(有 overflow 且非 visible)
2052
- // 这段纯粹是诊断用的(结果只进日志),且 getComputedStyle 会强制排版 —— 同样只在开关打开时跑
2053
- ccvLogD(function () {
2054
- var n = bar.parentElement, clip = 'none'
2055
- while (n && n !== document.documentElement) {
2056
- var ov = getComputedStyle(n).overflow
2057
- if (ov && ov !== 'visible') { clip = ccvDesc(n) + ' overflow=' + ov; break }
2058
- n = n.parentElement
2059
- }
2060
- return 'showSelbar: 裁剪祖先 ' + clip
2061
- })
1566
+ var CCV_ENTRY_TEXT = {
1567
+ display: 'flex', flexDirection: 'column', flex: '1 1 auto',
1568
+ gap: '3px', minWidth: 0,
2062
1569
  }
2063
- function hideSelbar() {
2064
- var bar = document.getElementById('ccv-selbar');
2065
- if (bar) bar.style.display = 'none';
2066
- // 取调用栈很贵,且这里一次鼠标松开可能被调多次 —— 只有 CCV_DEBUG 打开时才做
2067
- ccvLogD(function () {
2068
- var st = ''
2069
- try { st = (new Error()).stack || '' } catch (e) {}
2070
- var line = st.split('\n')[2] || ''
2071
- return 'hideSelbar: 调用 bar=' + (bar ? 'yes' : 'MISSING') + ' 来自 ' + String(line).trim()
2072
- })
1570
+ var CCV_ENTRY_TITLE = {
1571
+ fontSize: '14px', lineHeight: 1.4,
1572
+ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
2073
1573
  }
2074
-
2075
- // ↑ 已提到模块级:见文件上方「静态资源(模块级)」
2076
- // 当前话布标题:canvases 里找 activeId 对应的那条
2077
- let curTitle = "";
2078
- for (let i = 0; i < canvases.length; i++) {
2079
- if (canvases[i].id === activeId) { curTitle = canvases[i].title; break; }
1574
+ var CCV_ENTRY_DESC = {
1575
+ fontSize: '11px', lineHeight: 1.4,
1576
+ color: 'var(--dsw-alias-label-tertiary,#6b7280)',
1577
+ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
2080
1578
  }
2081
-
2082
- // ccv-docked 是个纯标记类(没有对应 CSS),用来在 DOM 上标明"停靠"这一种摆法
2083
- return h("div", { ref: panelRef, className: "ccv-panel ccv-docked", style: CCV_STYLE.panel },
2084
- h("div", {
2085
- className: "ccv-head",
2086
- // 读数平时透明,鼠标移进顶栏才浮现
2087
- onMouseEnter: () => { if (meterRef.current) meterRef.current.style.opacity = "0.45"; },
2088
- onMouseLeave: () => { if (meterRef.current) meterRef.current.style.opacity = "0"; }
1579
+ // 官方 hover 底色(拿不到就退回 bg-layer-2 ✓)
1580
+ var CCV_ENTRY_HOVER_BG = 'var(--dsw-alias-interactive-bg-hover,var(--dsw-alias-bg-layer-2,rgba(128,128,128,.12)))'
1581
+ // 居中的纵向容器(照官方 .guide ✓)
1582
+ function ccvGuideColumn(children) {
1583
+ return h('div', {
1584
+ style: {
1585
+ boxSizing: 'border-box',
1586
+ display: 'flex', flexDirection: 'column',
1587
+ justifyContent: 'center', alignItems: 'center',
1588
+ gap: '14px', minHeight: '100%', height: '100%',
1589
+ padding: '0 24px', overflow: 'auto',
1590
+ },
2089
1591
  },
2090
- // ─── 第一行:管话布(新建 / 切换 / 全屏)───
2091
- h("div", { className: "ccv-row ccv-row-top" },
2092
- h("button", {
2093
- className: "ccv-ibtn",
2094
- onClick: creating ? cancelCreate : startCreate,
2095
- title: creating ? "取消新建" : "新建话布"
2096
- }, creating ? "✕" : "+"),
2097
- creating
2098
- ? h("input", {
2099
- className: "ccv-ninput", autoFocus: true, value: newTitle,
2100
- placeholder: "话布标题,回车创建",
2101
- onChange: e => setNewTitle(e.target.value),
2102
- onKeyDown: e => {
2103
- if (e.key === "Enter") { e.preventDefault(); submitCreate(); }
2104
- else if (e.key === "Escape") { e.preventDefault(); cancelCreate(); }
2105
- }
2106
- })
2107
- : h("div", { className: "ccv-chipwrap" },
2108
- h("div", { className: "ccv-chip", onClick: toggleDrop, title: "点击切换话布" },
2109
- h("span", { className: "ccv-chip-title", title: curTitle || "未选话布" }, curTitle || "未选话布"),
2110
- h("span", { className: "ccv-chip-caret" }, "▾")
2111
- ),
2112
- // 切换下拉:absolute 挂在胶囊正下方(left:0 对齐胶囊左缘),
2113
- // 不是 fixed 浮层 —— 它属于顶栏的局部堆叠,不会脱离面板
2114
- dropOpen ? h("div", { className: "ccv-drop" },
2115
- canvases.length
2116
- ? canvases.map(c => h("div", { key: c.id, className: "ccv-drop-row" },
2117
- h("button", {
2118
- className: "ccv-drop-item", "data-on": c.id === activeId ? "1" : "0",
2119
- title: c.title,
2120
- onClick: () => { loadCanvas(c.id); setDropOpen(false); }
2121
- }, c.title),
2122
- h("button", {
2123
- className: "ccv-drop-x", title: "删除话布「" + c.title + "」(同时删除对应 md 文件)",
2124
- onClick: (e) => { e.stopPropagation(); deleteCanvasById(c.id, c.title); }
2125
- }, "×")
2126
- ))
2127
- : h("div", { className: "ccv-drop-empty" }, "还没有话布,点 + 新建一个")
2128
- ) : null
2129
- ),
2130
- h("div", { className: "ccv-spacer" }),
2131
- // 全屏、关闭都交给右侧栏标签栏自己做(官方那个 × 与我原来的 × 完全重复,
2132
- // 已于 2026-09-12 撤下),这里不再重复放按钮。
2133
- null
2134
- ),
2135
- // ─── 第二行:管当前文档(保存 / 重载)───
2136
- h("div", { className: "ccv-row ccv-row-doc" },
2137
- h("button", { className: "ccv-tbtn", onClick: saveCanvas, disabled: !activeId, title: "保存到服务端(停止输入 1.5 秒后也会自动存)" }, "保存"),
2138
- h("button", { className: "ccv-tbtn", onClick: () => { if (activeId) loadCanvas(activeId); }, disabled: !activeId, title: "从服务端拉取最新内容 —— AI 在对话里改过话布后用它同步。你还没提交的编辑会先替你保存,不会再被这一下清掉" }, "重载"),
2139
- h("button", { className: "ccv-ibtn", onClick: openFolder, title: "在文件管理器中打开本文档所在的文件夹",
2140
- dangerouslySetInnerHTML: { __html: '<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:block;margin:auto"><path d="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2"/></svg>' } }),
2141
- h("button", { className: "ccv-tbtn", onClick: manageRegistry, title: "登记文件夹:之后把登记文件夹内的文件拖进 DSH 窗口时,按真实路径 @ 引用(不产生副本)" }, "登记"),
2142
- h("div", { className: "ccv-spacer" }),
2143
- // 恢复提示:切标签会拆掉画布,切回来时如果捞到一份没写盘的草稿就说明一下,
2144
- // 否则用户会以为"我明明打过的字怎么自己变回去了"
2145
- restored ? h("span", {
2146
- className: "ccv-meter",
2147
- title: "这是你上次没来得及保存的内容,已自动补存回服务端",
2148
- style: { opacity: 1, color: "var(--dsw-alias-label-secondary, #6b7280)" }
2149
- }, "已恢复未保存内容") : null,
2150
- h("span", {
2151
- ref: meterRef, className: "ccv-meter",
2152
- title: "本标签的宽度"
2153
- })
2154
- )
2155
- ),
2156
- h("div", {
2157
- ref: editorRef, className: "ccv-editor", contentEditable: true, suppressContentEditableWarning: true, style: compact ? CCV_STYLE.editorCompact : CCV_STYLE.editorWide,
2158
- dangerouslySetInnerHTML: { __html: mdToHtml(content) },
2159
- onInput: () => { scheduleDraft(); clearTimeout(saveTimer.current); saveTimer.current = setTimeout(saveCanvas, 1500); },
2160
- onMouseUp: onEditorMouseUp,
2161
- onContextMenu: (e) => { e.preventDefault(); showContextMenu(e.clientX, e.clientY) }
2162
- }),
2163
- // width:max-content + flexWrap:nowrap —— 让栏按内容取自然宽度,
2164
- // 绝不被 left 位置挤窄;放不下时由 showSelbar 整体左移,而不是内部折行。
2165
- h("div", { id: "ccv-selbar", style: { position: "fixed", zIndex: 100000, display: "none", alignItems: "center", gap: 1, width: "max-content", maxWidth: "none", flexWrap: "nowrap", flexShrink: 0, background: "var(--dsw-specific-menu, var(--dsw-alias-bg-overlay, #fff))", color: "var(--dsw-alias-label-primary, #1a1a1a)", border: "1px solid var(--dsw-alias-border-l1, rgba(128,128,128,.3))", borderRadius: 8, boxShadow: "0 6px 20px rgba(0,0,0,.1)", padding: "3px 6px", userSelect: "none", WebkitUserSelect: "none", backdropFilter: "blur(12px) saturate(150%)", WebkitBackdropFilter: "blur(12px) saturate(150%)" } })
2166
- );
2167
- }
2168
-
2169
- // ─── 输入桥组件:把官方 inputActions / draft 暴露给划词栏 ──
2170
- // 新版 DSH 输入框是 Lexical 编辑器,直接改 DOM 无法可靠同步;
2171
- // slot 组件能拿到 inputActions.setDraft(官方接口),存到全局供「问AI」调用。
2172
- function InputBridge(props) {
2173
- var inputState = null
2174
- try { inputState = props.useInput ? props.useInput(function(s) { return s }) : null } catch (e) { inputState = null }
2175
- React.useEffect(function() {
2176
- if (props.inputActions) window.__ccvInputActions = props.inputActions
2177
- }, [])
2178
- React.useEffect(function() {
2179
- window.__ccvInputState = inputState
2180
- }, [inputState])
2181
- // 选区引用(侧模块 lib/selref.js)要用它换"会话手柄":
2182
- // sessions.scope(sessionId) → 会话作用域 → inputTriggers.sessionOf(它) → 输入管线控制器
2183
- // 只有会话作用域的槽位才拿得到 sessionId,所以从这儿挂出去。
2184
- React.useEffect(function() {
2185
- if (props.sessionId) window.__ccvSessionId = props.sessionId
2186
- }, [props.sessionId])
2187
- return null
2188
- }
1592
+ children,
1593
+ // 官方 .guide:after { flex: 0 10% } ✓ 视觉重心略上(照抄 ✓)
1594
+ h('div', { style: { flex: '0 10%', minHeight: 0 } }))
1595
+ }
2189
1596
 
2190
- // ─── 门厅页(右侧栏「开始」)的入口图标 ────────────────
2191
- // 门厅页会给每个注册的标签类型画一个入口胶囊,图标没给就画个占位方块。
2192
- // 这里画一块"画布 + 两行字"的小图,和「工作区文件」的文件夹图标同尺寸。
2193
- // 只需 { size, className } 两个属性,跟随 currentColor,主题自动适配。
2194
- function CcvGuideGlyph(props) {
2195
- var size = props && props.size ? props.size : 16;
2196
- return h("svg", {
2197
- width: size, height: size, viewBox: "0 0 16 16", fill: "none",
2198
- className: props ? props.className : undefined, "aria-hidden": "true"
2199
- },
2200
- h("rect", {
2201
- x: 2.1, y: 2.7, width: 11.8, height: 10.6, rx: 2.2,
2202
- stroke: "currentColor", strokeWidth: 1.2
2203
- }),
2204
- h("path", {
2205
- d: "M5.1 6.1h5.8M5.1 9.1h3.5",
2206
- stroke: "currentColor", strokeWidth: 1.2, strokeLinecap: "round"
2207
- })
2208
- );
2209
- }
1597
+ // ★ 功能入口卡(一级页用)—— 结构照官方 guide 的 entry ★
1598
+ function CcvEntry(props) {
1599
+ var hover = React.useState(false) // ← 组件顶层 ✓
1600
+ var hv = hover[0], setHv = hover[1]
1601
+ var e = props.entry || {}
1602
+ return h('div', {
1603
+ onClick: e.disabled ? undefined : e.onClick,
1604
+ onMouseEnter: e.disabled ? undefined : function () { setHv(true) },
1605
+ onMouseLeave: e.disabled ? undefined : function () { setHv(false) },
1606
+ title: e.hint || '',
1607
+ role: 'button',
1608
+ style: Object.assign({}, CCV_ENTRY_BASE, {
1609
+ cursor: e.disabled ? 'default' : 'pointer',
1610
+ opacity: e.disabled ? 0.55 : 1,
1611
+ background: (!e.disabled && hv) ? CCV_ENTRY_HOVER_BG : CCV_ENTRY_BASE.background,
1612
+ }),
1613
+ },
1614
+ h('div', { style: CCV_ENTRY_ICON }, e.icon || '·'),
1615
+ h('div', { style: CCV_ENTRY_TEXT },
1616
+ h('div', { style: CCV_ENTRY_TITLE }, e.title || ''),
1617
+ h('div', { style: CCV_ENTRY_DESC }, e.desc || '')),
1618
+ // 官方 entry 右侧没有箭头 ✗ —— 但"能点进去"这件事要有提示 ✓
1619
+ // 用小号 chevron,颜色跟描述同级 ✓ 不抢眼 ✓
1620
+ e.disabled
1621
+ ? h('div', { style: { fontSize: '11px', flex: 'none', color: 'var(--dsw-alias-label-tertiary,#6b7280)' } }, '规划中')
1622
+ : h('div', { style: { fontSize: '15px', flex: 'none', color: 'var(--dsw-alias-label-secondary,#8b8b8b)' } }, '›'))
1623
+ }
2210
1624
 
2211
- // ─── 插件注册 ──────────────────────────────────────
2212
- // ─── 停靠模式:右侧栏标签的正文 ────────────────────────
2213
- // 这里渲染的就是同一个 CanvasPanel,只是把它当"填满标签页的一块区域",
2214
- // 而不再是一个自己拖宽度、自己挤对话列的悬浮层。
2215
- // 右侧栏标签正文会收到一组标准 props(useTabInfo / sessionId / actions …)。
2216
- // 将来若要加「关闭本标签」之类的入口,从 props.useTabInfo().tab.actions 取就行 ——
2217
- // 官方标签栏自带的 × 已经在做这件事,所以我方不再放重复的关闭按钮(2026-09-12 撤下)。
2218
- function CanvasDockBody() {
2219
- return h("div", {
2220
- style: {
2221
- display: "flex", flexDirection: "column",
2222
- height: "100%", minHeight: 0, minWidth: 0, overflow: "hidden",
2223
- background: "var(--dsw-alias-bg-layer-1, #fff)"
1625
+ // ★ 错误边界(2026-10-23 加,10-24 挪到外面)★
1626
+ // 上一次标签页纯空白 ✗ —— 里面一点线索都没有 ✓ 查了半天 ✓
1627
+ // 有了它:组件崩了会显示"这里出错了:<原因>"✗ 而不是空白 ✓
1628
+ // ⚠️ React 里**只有 class 组件能做错误边界** ✗ 函数组件做不了 ✓
1629
+ // (要 static getDerivedStateFromError / componentDidCatch ✓)
1630
+ // ⚠️ 拿不到 React.Component → 跳过边界 ✓(不能因此再崩一次 ✗)
1631
+ var CcvErrorBoundary = null
1632
+ try {
1633
+ if (React.Component) {
1634
+ CcvErrorBoundary = class extends React.Component {
1635
+ constructor(p) { super(p); this.state = { err: null } }
1636
+ static getDerivedStateFromError(e) { return { err: e } }
1637
+ componentDidCatch(e) {
1638
+ try { ccvLog('话布标签页渲染出错: ' + ((e && e.message) || e)) } catch (_) {}
1639
+ }
1640
+ render() {
1641
+ if (this.state && this.state.err) {
1642
+ return h('div', { style: { padding: '14px', fontSize: '13px', color: 'var(--dsw-alias-state-error-primary,#d33)', wordBreak: 'break-word' } },
1643
+ '话布标签页出错了:' + ((this.state.err && this.state.err.message) || String(this.state.err)),
1644
+ h('div', { style: { marginTop: '6px', fontSize: '12px', color: 'var(--dsw-alias-label-secondary,#6b7280)' } },
1645
+ '(详情见 %TEMP%\\ccv-selbar.log)'))
1646
+ }
1647
+ return this.props.children
1648
+ }
1649
+ }
2224
1650
  }
2225
- }, h(CanvasPanel, { ctx: ccvCtx }));
2226
- }
1651
+ } catch (e) { ccvLog('错误边界创建失败(跳过): ' + (e && e.message)); CcvErrorBoundary = null }
2227
1652
 
2228
- // sessions / inputTriggers 是「选区引用」(侧模块 lib/selref.js)要用的:
2229
- // sessions —— 由会话 id 取该会话的作用域,再拿输入框句柄
2230
- // inputTriggers —— 注册「话布选区」引用源(新方案的地基)
2231
- // ⚠️ 与 sidebarRightTabs 同理:**必须写进这里,不能运行时 ctx.get 现取** ——
2232
- // 未声明的服务取不到。2026-09-14 实测:不加这两项时 selref 自检报
2233
- // 「暂缺 sessions / inputTriggers」,复检仍缺;补进 inject 后才就绪。
2234
- // 提供方(镜像里都已启用,见 dsh --profile web --dump-config):
2235
- // sessions ← @deepseek-ai/dsh-api-session-controller
2236
- // inputTriggers ← @deepseek-ai/dsh-client-ui-input-trigger
2237
- const inject = ["slots", "conversation", "sidebarRightTabs", "sidebarRight", "sessions", "inputTriggers"];
2238
1653
 
2239
- // 幂等注入一段 CSS:同一个 id 只插一次,重复调用是空操作。
2240
- // apply 有可能被走多遍(宿主重挂插件),靠这里去重。
2241
- function ensureStyle(id, cssText) {
2242
- if (typeof document === 'undefined') return;
2243
- if (document.getElementById(id)) return; // 已注入过,不重复插
2244
- var el = document.createElement('style');
2245
- el.id = id;
2246
- el.textContent = cssText;
2247
- document.head.appendChild(el);
1654
+ return {
1655
+ CcvTabGlyph: CcvTabGlyph, CCV_ROW_BASE: CCV_ROW_BASE, CcvRow: CcvRow,
1656
+ ccvGuideColumn: ccvGuideColumn, CcvEntry: CcvEntry,
1657
+ CcvErrorBoundary: CcvErrorBoundary,
2248
1658
  }
1659
+ }
2249
1660
 
2250
1661
  function apply(ctx) {
2251
1662
  ccvCtx = ctx; // tab 正文是另一个组件,用的就是这个引用
@@ -2260,6 +1671,8 @@ window.__ModuleLoader__.load({
2260
1671
  try {
2261
1672
  window.__ccvCtx = ctx;
2262
1673
  if (typeof window.__ccvSelRefBoot === 'function') window.__ccvSelRefBoot(ctx);
1674
+ // 接管 DSH 自带查看器里的 .md(侧模块 lib/viewer-md.js):先只读,验契约
1675
+ if (typeof window.__ccvViewerBoot === 'function') window.__ccvViewerBoot(ctx);
2263
1676
  } catch (e) { ccvLog('侧模块桥接失败: ' + (e && e.message)); }
2264
1677
  // ─── 会话内容限流 ──────────────────────────────
2265
1678
  // DSH 的会话消息不主动给 <img> 设 max-width,AI 生成的大图 / 截图会撑破
@@ -2343,8 +1756,13 @@ window.__ModuleLoader__.load({
2343
1756
  ].join('\n')
2344
1757
  );
2345
1758
  // ─── Markdown 渲染样式(新 editor 用 .ccv-editor;旧 03-styles.js 的 .ccv-wysiwyg 已弃用)
2346
- ensureStyle('ccv-md-style',
2347
- [
1759
+ // ⚠️ 这份 CSS 文本要**借给查看器**用(见 lib/viewer-md.js):
1760
+ // 面板的 markdown 样式作用域是 `.ccv-panel .ccv-editor`,而查看器的正文
1761
+ // 是 `[data-ccv-md-body]` —— 两处渲染用的是**同一个 mdToHtml**,
1762
+ // 所以差别只在样式:同一份 <table>,有边框就是表格,没边框就看起来像纯文本。
1763
+ // 2026-10-04 主人正是拿两张截图问"为什么两边显示不一样"。
1764
+ // 做法:**一份规则两处用**(换掉作用域前缀),不复制、不会漂移。
1765
+ var ccvMdCss = [
2348
1766
  '.ccv-panel .ccv-editor > *:first-child{margin-top:0}',
2349
1767
  '.ccv-panel .ccv-editor > *:last-child{margin-bottom:0}',
2350
1768
  '.ccv-panel .ccv-editor h1{font-size:1.6em;border-bottom:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.3));padding-bottom:.2em;margin:.6em 0 .4em;font-weight:600;line-height:1.3}',
@@ -2354,6 +1772,15 @@ window.__ModuleLoader__.load({
2354
1772
  '.ccv-panel .ccv-editor h5{font-size:1em;margin:.7em 0 .45em;font-weight:600;line-height:1.35}',
2355
1773
  '.ccv-panel .ccv-editor h6{font-size:.95em;margin:.7em 0 .45em;font-weight:600;line-height:1.35;color:var(--dsw-alias-label-secondary,rgba(0,0,0,.65))}',
2356
1774
  '.ccv-panel .ccv-editor p{margin:.5em 0}',
1775
+ // 空段落不显示。2026-10-11 主人报「查看器里引用块行距很大」:
1776
+ // 引用块原文是带空行的(`> **依据**…` / `> ` / `> **本文件回答一件事**…`),
1777
+ // 块渲染器**每一行都变成一个 <p>**(见 renderBlocks 末尾),空行就变成空段落
1778
+ // → 每处多出一整行 + 边距 ≈ 52px,而正常行距只有 24.5px。
1779
+ // 面板里看不出来,是因为编辑区是 contentEditable —— 浏览器会把空段落吃掉/合并;
1780
+ // 查看器是纯静态 HTML,空段落原样保留,于是全暴露出来。
1781
+ // 两条都写上:`:empty` 管 `<p></p>`,`:has(> br:only-child)` 管 `<p><br></p>`。
1782
+ '.ccv-panel .ccv-editor p:empty{display:none}',
1783
+ '.ccv-panel .ccv-editor p:has(> br:only-child){display:none}',
2357
1784
  '.ccv-panel .ccv-editor blockquote{border-left:3px solid var(--dsw-alias-border-l2,rgba(128,128,128,.4));margin:.6em 0;padding:4px 12px;opacity:.9;color:var(--dsw-alias-label-secondary,rgba(0,0,0,.7))}',
2358
1785
  '.ccv-panel .ccv-editor blockquote > *:first-child{margin-top:0}',
2359
1786
  '.ccv-panel .ccv-editor blockquote > *:last-child{margin-bottom:0}',
@@ -2361,6 +1788,17 @@ window.__ModuleLoader__.load({
2361
1788
  '.ccv-panel .ccv-editor .ccv-hl{background:rgba(250,204,21,.16);border-left:3px solid rgba(234,179,8,.55);border-radius:6px;padding:8px 14px;margin:.6em 0}',
2362
1789
  '.ccv-panel .ccv-editor .ccv-hl > *:first-child{margin-top:0}',
2363
1790
  '.ccv-panel .ccv-editor .ccv-hl > *:last-child{margin-bottom:0}',
1791
+ // ⚠️ 2026-10-29 主人:「现在高亮块有问题」
1792
+ // 高亮块在**查看器**里没有任何样式 ✗
1793
+ // 上面那三条规则写在"面板样式表"(ccvStyle)里 ✗
1794
+ // 而查看器拿的是 mdCss(mdCssFor → 作用域替换)✗ → .ccv-hl 变成普通 div ✓
1795
+ // → 高亮块在右侧文档里"没底色、没左边线",看起来就是普通段落 ✓
1796
+ // 所以这里在 **mdCss** 里再写一份(面板与查看器共用同一条规则 ✓)
1797
+ '.ccv-panel .ccv-editor .ccv-hl{background:rgba(250,204,21,.16);border-left:3px solid rgba(234,179,8,.55);border-radius:6px;padding:8px 14px;margin:.6em 0}',
1798
+ '.ccv-panel .ccv-editor .ccv-hl p{margin:.2em 0}',
1799
+ // 行内高亮(==文字== ↔ <mark>):查看器同样没有这条规则 ✗ → 浏览器默认的
1800
+ // 纯黄底 + 黑字,在深色主题里像一块"荧光砖" ✓ 一并收进来 ✓
1801
+ '.ccv-panel .ccv-editor mark{background:rgba(250,204,21,.28);color:inherit;padding:0 3px;border-radius:3px}',
2364
1802
  '.ccv-panel .ccv-editor input[type="checkbox"]{cursor:pointer}',
2365
1803
  '.ccv-panel .ccv-editor pre{background:var(--dsw-alias-markdown-code-block,rgba(128,128,128,.12));padding:10px 12px;border-radius:6px;overflow-x:auto;white-space:pre-wrap;margin:.6em 0;font-family:var(--font-mono,ui-monospace,Menlo,monospace);font-size:.95em}',
2366
1804
  '.ccv-panel .ccv-editor code{background:var(--dsw-alias-markdown-inline-code,rgba(128,128,128,.16));padding:1px 4px;border-radius:3px;font-family:var(--font-mono,ui-monospace,Menlo,monospace);font-size:.95em}',
@@ -2373,6 +1811,11 @@ window.__ModuleLoader__.load({
2373
1811
  '.ccv-panel .ccv-editor table{border-collapse:separate;border-spacing:0;margin:.8em 0;width:100%;max-width:100%;border:1px solid var(--dsw-alias-border-l1,rgba(128,128,128,.55));border-radius:6px;overflow:hidden}',
2374
1812
  '.ccv-panel .ccv-editor th,.ccv-panel .ccv-editor td{border:1px solid var(--dsw-alias-border-l1,rgba(128,128,128,.55));padding:8px 12px;text-align:left;vertical-align:top}',
2375
1813
  '.ccv-panel .ccv-editor th{background:var(--dsw-alias-interactive-bg-active,rgba(128,128,128,.18));font-weight:600}',
1814
+ // ⚠️ 2026-10-29 主人(截图):插入表格后**正文行高度塌成一条线**、表头却很高
1815
+ // 原因:空的 <td></td> 在 contentEditable 里没有行高 ✗(有文字的 <th> 有 ✓)
1816
+ // 双重保险:① 插入时给单元格放 <br>(viewer 的 insertTableHtml 已改)
1817
+ // ② 这里给 :empty 补一条零宽字符 —— 就算 <br> 被删掉也不会塌 ✓
1818
+ '.ccv-panel .ccv-editor td:empty::after,.ccv-panel .ccv-editor th:empty::after{content:"\\200b"}',
2376
1819
  '.ccv-panel .ccv-editor tr:nth-child(even){background:var(--dsw-alias-interactive-bg-hover,rgba(128,128,128,.08))}',
2377
1820
  '.ccv-panel .ccv-editor tr:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(59,130,246,.08))}',
2378
1821
  '.ccv-panel .ccv-editor hr{border:none;border-top:1px solid var(--dsw-alias-border-l1,rgba(128,128,128,.3));margin:1em 0}',
@@ -2384,7 +1827,15 @@ window.__ModuleLoader__.load({
2384
1827
  '.ccv-panel .ccv-editor em{font-style:italic}',
2385
1828
  '.ccv-panel .ccv-editor del{text-decoration:line-through;opacity:.75}'
2386
1829
  ].join('\n')
2387
- );
1830
+ ensureStyle('ccv-md-style', ccvMdCss);
1831
+ // 借给查看器:把作用域前缀换成它正文的标记,同一份规则直接复用
1832
+ try {
1833
+ if (window.__ccvKit) {
1834
+ window.__ccvKit.mdCssFor = function (scope) {
1835
+ return ccvMdCss.split('.ccv-panel .ccv-editor').join(scope);
1836
+ };
1837
+ }
1838
+ } catch (_) {}
2388
1839
  // ─── JS 终极兜底 ──────────────────────────────
2389
1840
  // 即使 CSS 选择器漏、即使 inline style 用了 !important (CSS 覆盖不了
2390
1841
  // inline !important) 都能压住。
@@ -2467,71 +1918,45 @@ window.__ModuleLoader__.load({
2467
1918
  } catch (e) {}
2468
1919
  }
2469
1920
 
2470
- // ─── 对外桥接(原先由顶栏按钮组件注册,现改由插件自己提供)──────
2471
- // 对话里的文档名链接、上传卡片链接(lib/doclink.js)靠这两个全局量打开
2472
- // 话布、并判断画布是否已打开。撤掉顶栏按钮后若不补上,那些链接会点不动
2473
- // (doclink 日志里会出现「话布桥接未就绪」)。
2474
- // __ccvPanelOpen 必须做成"取值即判断"的 getter:doclink 是在点击那一刻读它的,
2475
- // 要反映此刻有没有画布,而不是某个组件的挂载状态。
2476
- window.__ccvCanvasToggle = function () {
2477
- var svc = null;
2478
- try { svc = ctx.sidebarRight; } catch (_) {}
2479
- if (!svc || typeof svc.openTab !== "function") {
2480
- ccvLog("打开话布失败:拿不到右侧栏接口");
2481
- return false;
2482
- }
2483
- try { svc.openTab(CCV_DOCK_KIND); return true; }
2484
- catch (e) { ccvLog("打开话布失败: " + (e && e.message)); return false; }
2485
- };
2486
- try {
2487
- Object.defineProperty(window, "__ccvPanelOpen", {
2488
- configurable: true,
2489
- get: function () {
2490
- try { return !!document.querySelector(".ccv-panel"); } catch (_) { return false; }
2491
- }
2492
- });
2493
- } catch (e) { ccvLog("注册画布状态桥接失败: " + (e && e.message)); }
2494
- // 注册输入桥(隐藏组件,暴露 inputActions.setDraft 给划词栏)
2495
- ctx.slots.inject("conversation.input.dock", () =>
2496
- ctx.slots.register({
2497
- name: "conversation.input.dock",
2498
- id: "collab-canvas-input-bridge",
2499
- order: 999,
2500
- label: () => "画布输入桥"
2501
- }, InputBridge)
2502
- );
2503
-
2504
- // ─── 停靠模式:把真画布挂进 DSH 内置右侧栏 ─────────────────
2505
- // 两阶段注册,和官方 sidebar-files 完全同款:
2506
- // ① 声明一个「页面型」标签类型(不认地址,靠 openTab 打开)
2507
- // ② 把正文注册到 sidebar.right.pane.tab 槽位,key = 上面那个 id
2508
- // 注意:这两个服务必须写进 inject(声明依赖),不能运行时 ctx.xxx 现取 ——
2509
- // 现取拿不到,而且失败会被静默吞掉,按钮出来了却点不开。
2510
- try {
2511
- ctx.effect(function () {
2512
- return ctx.sidebarRightTabs.register({
2513
- id: CCV_DOCK_KIND,
2514
- kind: CCV_DOCK_KIND,
2515
- priority: "extension",
2516
- title: function () { return "话布"; },
2517
- // 门厅页(右侧栏「开始」)的入口胶囊。给了 guide 就出现在那一页,
2518
- // 点它 = 在本标签的位置打开话布,所以门厅是"门"而不是"常驻页"。
2519
- // order 越小越靠前(官方的「工作区文件」是 10)。
2520
- guide: [{
2521
- order: 20,
2522
- title: function () { return "话布"; },
2523
- description: function () { return "画布式文档:随手记与整理,可存进工作区"; },
2524
- icon: CcvGuideGlyph
2525
- }]
2526
- });
2527
- }, "collab-canvas: 右侧栏 tab 类型");
2528
- } catch (e) { ccvLog("右侧栏 tab 类型注册失败: " + (e && e.message)); }
2529
-
2530
- try {
2531
- ctx.slots.inject("sidebar.right.pane.tab", function () {
2532
- return ctx.slots.register({ name: "sidebar.right.pane.tab", key: CCV_DOCK_KIND }, CanvasDockBody);
2533
- });
2534
- } catch (e) { ccvLog("右侧栏 tab 正文注册失败: " + (e && e.message)); }
1921
+ // ─── 右侧栏「话布」标签页:**功能入口**(2026-10-11 恢复)────────────
1922
+ // 定位(主人原话):话布插件不止编辑 markdown,以后会有 UI 实时可视编辑网页等功能,
1923
+ // 所以需要一个**话布标签页**,打开可以**选功能** ✓
1924
+ // —— 所以这里是"功能清单",**不是某个编辑器的宿主** ✗
1925
+ //
1926
+ // ⚠️ 历史(2026-10-11 ~ 10-22):那时查过这个槽(sidebar.right.pane.tab)的
1927
+ // ownerProps 是**空的** ✗ → 结论是"没法从这里打开某个文件"✗
1928
+ // 于是清单只列文字,写着"打开它们请走文件树"✗
1929
+ // —— **那个结论是错的** ✗ 漏看了一个服务 ✓
1930
+ //
1931
+ // ★ 2026-10-22 主人报:「这里点击跳转到文档选择」★
1932
+ // 查官方包(dsh-client-ui-sidebar-right/lib/types/client/service.d.ts)找到:
1933
+ // ctx.sidebarRight.openResource(address, options?) ✓✓
1934
+ // · address 必须是 `dsh-resource://<type>/…` 形式 ✓
1935
+ // · 它会 claim 资源 / 放置标签 / **展开右栏** / 记录导航,一次做完 ✓
1936
+ // · 地址不合法或没人认领 → **抛异常** ✓(官方注释:那是接线错误 ✓)
1937
+ // → 所以**点清单里的一行就能跳到那篇文档** ✓ 不用再走文件树 ✓
1938
+ //
1939
+ // 两阶段注册(照官方 sidebar-files):
1940
+ // ① sidebarRightTabs.register —— 声明一个"页面型"标签类型
1941
+ // ② slots.register 到 sidebar.right.pane.tab,key 与上面 id 一致
1942
+ // ⚠️ 这两个服务必须写回 inject 声明(不能运行时 ctx.get 现取 ✗)
1943
+ var CCV_TAB_ID = 'collab-canvas'
1944
+ // ═══ 画布小工具集(地址 / 打开文档 / 文件树行 / 入口卡片)═══
1945
+ // 17 个定义里,apply 只用得到这 6 个 ✓(机器核对)
1946
+ var __kit = ccvMakeCanvasKit({ ctx: ctx })
1947
+ var CcvTabGlyph = __kit.CcvTabGlyph, CCV_ROW_BASE = __kit.CCV_ROW_BASE
1948
+ var CcvRow = __kit.CcvRow, ccvGuideColumn = __kit.ccvGuideColumn
1949
+ var CcvEntry = __kit.CcvEntry, CcvErrorBoundary = __kit.CcvErrorBoundary
1950
+ // ═══ 右侧栏「话布」标签页(整块 UI 在模块级 ccvMakeCanvasTab ✓)═══
1951
+ // 依赖只有 7 个 ✓ 返回也只有 CcvFeatureTab ✓(都是机器算过、非目测)
1952
+ var CcvFeatureTab = ccvMakeCanvasTab({
1953
+ CCV_ROW_BASE: CCV_ROW_BASE, CCV_TAB_ID: CCV_TAB_ID,
1954
+ CcvEntry: CcvEntry, CcvErrorBoundary: CcvErrorBoundary,
1955
+ CcvRow: CcvRow, CcvTabGlyph: CcvTabGlyph,
1956
+ ccvGuideColumn: ccvGuideColumn,
1957
+ ctx: ctx,
1958
+ }).CcvFeatureTab
1959
+ // ─── 以后加功能:入口就在上面那个 CcvFeatureTab 里 ──────────────
2535
1960
  }
2536
1961
 
2537
1962
  exports.apply = apply;