collab-canvas 0.3.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 ADDED
@@ -0,0 +1,2541 @@
1
+ // 画布编辑器客户端模块——通过 DSH composition 客户端加载器加载
2
+ window.__ModuleLoader__.load({
3
+ id: "collab-canvas",
4
+ factory: (require) => {
5
+ var module = { exports: {} };
6
+ var exports = module.exports;
7
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
8
+
9
+ // 独立功能模块动态挂载(源码各自单文件维护,由 host 端点提供,读盘即最新,
10
+ // 改完刷新页面即可)。引导失败只影响对应功能,画布本体不受影响。
11
+ try {
12
+ if (!window.__ccvSideModulesBooted) {
13
+ window.__ccvSideModulesBooted = true;
14
+ ["/api/canvas/docdrop.js", "/api/canvas/doclink.js", "/api/canvas/selref.js"].forEach(function (u) {
15
+ var name = u.slice(u.lastIndexOf("/") + 1);
16
+ fetch(u + "?ts=" + Date.now()).then(function (r) {
17
+ ccvLog(name + ": 源码拉取 HTTP " + r.status);
18
+ return r.ok ? r.text() : "";
19
+ }).then(function (src) {
20
+ if (!src) { ccvLog(name + ": 源码为空,跳过"); return; }
21
+ try {
22
+ new Function(src)();
23
+ ccvLog(name + ": 模块执行完成");
24
+ } catch (e) { ccvLog(name + ": 模块执行失败 " + (e && e.message)); }
25
+ }).catch(function (e) { ccvLog(name + ": 拉取失败 " + (e && e.message)); });
26
+ });
27
+ }
28
+ } catch (e) { try { ccvLog("侧模块引导异常 " + (e && e.message)); } catch (_) {} }
29
+
30
+ const React = require("react");
31
+ const { createElement: h, useState, useEffect, useRef } = React;
32
+
33
+ // ─── 诊断日志(排障用,可整段删除)────────────────────
34
+ // 本环境开不了浏览器控制台,把划词链路的关键判定点发到 host,
35
+ // 由 host 落盘到 %TEMP%/ccv-selbar.log,之后直接读文件复盘。
36
+ // ─── 会话标识:侧边栏「当前选中会话行」的标题 ───
37
+ // 选中行有独立的 selected 类,切会话即变、同会话稳定——比窗口标题可靠
38
+ //(窗口标题会随视图/加载状态变成无会话名的形态,导致键漂移)
39
+ function hashStr(s) {
40
+ let h = 5381;
41
+ for (let i = 0; i < s.length; i++) { h = ((h << 5) + h + s.charCodeAt(i)) >>> 0; }
42
+ return h.toString(36);
43
+ }
44
+ // 标题形如 "DeepSeek Harness - <会话名> — DeepSeek Harness",随会话切换变化;
45
+ // 哈希保证不同会话不同键,同会话稳定。每个会话的画布清单互相独立。
46
+ function activeSessionTitle() {
47
+ try {
48
+ const row = document.querySelector('[class*="sessionRow"][class*="selected"]') ||
49
+ document.querySelector('[class*="sessionRow"][class*="active"]');
50
+ if (!row) return null;
51
+ const t = row.querySelector('[class*="title"]');
52
+ const name = ((t || row).textContent || "").trim();
53
+ return name || null;
54
+ } catch (_) {}
55
+ return null;
56
+ }
57
+ function sessionKey() {
58
+ const name = activeSessionTitle();
59
+ if (name) return "sess-" + hashStr(name);
60
+ const t = (document.title || "默认会话").trim() || "默认会话";
61
+ return "t" + hashStr(t);
62
+ }
63
+ function canvasApi(path) {
64
+ try { return path + (path.indexOf("?") >= 0 ? "&" : "?") + "sid=" + encodeURIComponent(sessionKey()); } catch (_) { return path; }
65
+ }
66
+ function ccvLog(msg) {
67
+ try {
68
+ fetch("/api/canvas/log", {
69
+ method: "POST",
70
+ headers: { "Content-Type": "application/json" },
71
+ body: JSON.stringify({ lines: [String(msg)] })
72
+ }).then(function () {}, function () {});
73
+ } catch (_) {}
74
+ }
75
+ // 只在高频路径上用的日志:CCV_DEBUG 关着时连字符串拼接都不做(msg 可传函数,按需求值)
76
+ function ccvLogD(msg) {
77
+ if (!CCV_DEBUG) return;
78
+ try { ccvLog(typeof msg === "function" ? msg() : msg); } catch (_) {}
79
+ }
80
+ // ─── 子模块共用的小工具 ────────────────────────────────
81
+ // docdrop.js / doclink.js 是「单文件、由 host 按请求供源码」的模块:改完刷新页面即生效、
82
+ // 不用重启。代价是它们没法互相 require,只能复制代码 —— 日志、轻提示条、会话查询串
83
+ // 因此在三处各存了一份。这里挂个全局命名空间给它们取用;取不到就各自退回自带的副本,
84
+ // 所以加载顺序变了也不会坏。
85
+ window.__ccvKit = {
86
+ // 统一走 /api/canvas/log;tag 会前置成 [tag]
87
+ log: function (msg, tag) {
88
+ ccvLog(tag ? "[" + tag + "] " + msg : msg);
89
+ },
90
+ // 轻提示条。id 由调用方给:原来两处各用自己的 id、各自去重,这里保持同样口径。
91
+ toast: function (msg, id) {
92
+ try {
93
+ var tid = id || "ccv-kit-toast";
94
+ var old = document.getElementById(tid);
95
+ if (old) old.remove();
96
+ var t = document.createElement("div");
97
+ t.id = tid;
98
+ t.textContent = msg;
99
+ t.style.cssText = "position:fixed;left:50%;bottom:96px;transform:translateX(-50%);" +
100
+ "z-index:2147483000;background:var(--dsw-alias-label-primary,#1a1a1a);" +
101
+ "color:var(--dsw-alias-bg-overlay,#fff);font-size:13px;padding:8px 14px;" +
102
+ "border-radius:8px;box-shadow:0 6px 20px rgba(0,0,0,.25);pointer-events:none;" +
103
+ "opacity:1;transition:opacity .3s";
104
+ document.body.appendChild(t);
105
+ setTimeout(function () { t.style.opacity = "0" }, 2200);
106
+ setTimeout(function () { if (t.parentNode) t.remove() }, 2600);
107
+ } catch (_) {}
108
+ },
109
+ // 会话查询串片段(形如 "sid=xxx")。刻意读 window.__ccvSid 而不是直接调 sessionKey():
110
+ // 与子模块原来的口径完全一致(首帧 __ccvSid 还没算出来时退成 sid=default)。
111
+ sidQ: function () {
112
+ try { var sid = window.__ccvSid; return sid ? "sid=" + encodeURIComponent(sid) : "sid=default"; } catch (_) { return "sid=default"; }
113
+ }
114
+ };
115
+ // 会话键同步:轮询当前会话键,变化时更新全局 sid(docdrop/doclink 读取)
116
+ try {
117
+ if (!window.__ccvSidTimer) {
118
+ window.__ccvSidTimer = setInterval(function () {
119
+ try {
120
+ const k = sessionKey();
121
+ if (window.__ccvSid !== k) {
122
+ const first = !window.__ccvSid;
123
+ window.__ccvSid = k;
124
+ ccvLog("会话键" + (first ? "" : " 更新") + ": " + k + (activeSessionTitle() ? " | 侧栏选中行命中" : " | 未识别到选中会话"));
125
+ }
126
+ } catch (_) {}
127
+ }, 2000);
128
+ }
129
+ } catch (_) {}
130
+ // 会话键诊断:每次页面加载记录一次解析结果,便于排障
131
+ try {
132
+ if (!window.__ccvSessKeyLogged) {
133
+ window.__ccvSessKeyLogged = true;
134
+ ccvLog("会话键: " + sessionKey() + " | 窗口标题: " + (document.title || ""));
135
+ }
136
+ } 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
+ // 🔍 高频路径的调试日志开关。默认 false —— 因为"鼠标松开 / 划词判定 / 隐藏划词栏"
149
+ // 这类动作一秒能触发几十次,每次都在拼调用栈并发一条网络请求写盘,属于实打实的卡顿源。
150
+ // 平时静默;排查划词相关问题时把它改成 true,这些日志就会照旧出现。
151
+ // 低频日志(各种 catch 里的失败原因)不受影响,永远都会记。
152
+ var CCV_DEBUG = false;
153
+ // client.js 的版本标记。改动后**必须**在这里 +1,否则无法从日志核对浏览器
154
+ // 加载的是哪一版(本环境开不了控制台,日志是唯一的核对通道)。
155
+ // v1 2026-09-12 起:并发保护(knownVer + 冲突处理)、草稿基准版本校验、
156
+ // 行内规则先抽出后还原、列表往返修复
157
+ // v2 2026-09-14:引用块带上来源 @md 路径(ccvDocMention),
158
+ // 多行选区逐行加 ">" 引号
159
+ // v3 2026-09-14:@ 来源改用 host 给的真实绝对路径(含空格走 @"..." 引号形式),
160
+ // 不再自己拼相对路径 canvas-docs/<标题>.md
161
+ // v4 2026-09-14:「过期草稿」提示改为只提示一次(加 notified 标记),并缩短文案
162
+ // v5 2026-09-14:代码围栏的语言标记不再丢失(mdToHtml 存到 <pre data-lang>,
163
+ // htmlToMd 读回);补了往返测试用例堵住这个盲区
164
+ var CCV_CLIENT_VERSION = 5;
165
+ var CCV_DRAFT_KEY = "ccv-draft-cache"; // { [canvasId]: { content, at, base } }
166
+ var ccvCtx = null; // 模块级 ctx:tab 正文是独立组件,拿不到 apply 的闭包
167
+ var ccvLiveEditor = null; // 当前编辑区 DOM。组件卸载后 React 会把 ref 置空,
168
+ var ccvLiveCanvasId = null; // 但我们自己留的这份引用仍能读到用户刚敲的字。
169
+ var ccvLiveVer = 0; // 本机已知的服务端版本号(组件内 versionRef 的镜像)
170
+ function ccvDrafts() {
171
+ try { var o = JSON.parse(localStorage.getItem(CCV_DRAFT_KEY) || "{}"); return o && typeof o === "object" ? o : {}; } catch (_) { return {}; }
172
+ }
173
+ function draftGet(id) {
174
+ if (!id) return null;
175
+ var d = ccvDrafts()[id];
176
+ return d && typeof d.content === "string" ? d : null;
177
+ }
178
+ // 大文档(画布内嵌图片时动辄几百 KB)不进 localStorage:写配额报错比丢草稿更烦人。
179
+ var CCV_DRAFT_MAX = 400 * 1024;
180
+ // base = 这份草稿是基于服务端哪一版写出来的。**必须记**:
181
+ // loadCanvas 恢复草稿后会补写回服务端,若拿服务端的「当前版本」当 knownVer,
182
+ // 一份基于旧版本的草稿就会被当成合法写入、直接覆盖新内容 ——
183
+ // 话布优化文档两次被整段抹掉都是这个路径干的。
184
+ function draftSet(id, content, base) {
185
+ if (!id || typeof content !== "string") return;
186
+ if (content.length > CCV_DRAFT_MAX) return;
187
+ var b = (typeof base === "number") ? base : (typeof ccvLiveVer === "number" ? ccvLiveVer : null);
188
+ // notified 每次写新草稿都归零:新内容可能又是一份会过期的草稿,该提示还得提示
189
+ 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
+ }
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
+ function draftDrop(id) {
202
+ if (!id) return;
203
+ try { var all = ccvDrafts(); if (all[id]) { delete all[id]; localStorage.setItem(CCV_DRAFT_KEY, JSON.stringify(all)); } } catch (_) {}
204
+ }
205
+ // 只有「存盘成功的那一版」才清草稿:如果这期间用户又敲了新内容,草稿得更新,不能清。
206
+ function draftDropIf(id, md) {
207
+ var d = draftGet(id);
208
+ if (d && d.content === md) draftDrop(id);
209
+ }
210
+
211
+ // 窄屏(手机/平板竖屏):分栏拖拽没法用,话布一律全屏铺满。
212
+ // 阈值 768 是常见断点;同时认 coarse pointer(触屏)兜底。
213
+ 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
+
220
+ // ─── Markdown ↔ HTML 转换(纯函数)────────────────────
221
+ // 2026-09-12 从 CanvasPanel 里搬到这里。理由:这两个只依赖传进来的 DOM / 字符串,
222
+ // 不碰任何组件状态,却被写在组件内部 —— 组件每渲染一次就重新创建一遍它们,
223
+ // 而组件在打字时每次输入都会重渲染。搬出来同时让巨型组件少了 413 行。
224
+ // 搬动有等价性测试守着(工作区 _md_test.js:改前改后 22 个用例的产物逐字节一致)。
225
+ // ⚠️ 别再搬回组件里 —— 它们和组件状态无关,放回去只会重新引入上面的浪费。
226
+ function htmlToMd(root) {
227
+ var fnDefs = [];
228
+ // 对齐:execCommand('justifyCenter'/'justifyRight') 会在块元素上留 align 属性
229
+ // 或 text-align 内联样式。markdown 没有对齐语法,存成 ::: center/right 围栏,
230
+ // 渲染端 renderBlocks 负责转回 <div style="text-align:...">
231
+ function blockAlign(el) {
232
+ if (!el || !el.getAttribute) return "";
233
+ var a = (el.getAttribute("align") || "").toLowerCase();
234
+ if (a === "center" || a === "right") return a;
235
+ var m = (el.getAttribute("style") || "").match(/text-align\s*:\s*(center|right)/i);
236
+ if (m) return m[1].toLowerCase();
237
+ try { var ta = el.style && el.style.textAlign; if (ta === "center" || ta === "right") return ta; } catch (_) {}
238
+ return "";
239
+ }
240
+ function withAlign(al, md) {
241
+ if (!al) return md;
242
+ return "::: " + al + "\n" + md.replace(/\n*$/, "\n") + ":::\n";
243
+ }
244
+ function withFence(kind, md) {
245
+ return "::: " + kind + "\n" + md.replace(/\n*$/, "\n") + ":::\n";
246
+ }
247
+ // 编辑器里 <img>/<a> 的 src/href 是 host 读文件接口地址(插入时就要能显示);
248
+ // 存盘换回 attachments/ 相对路径,md 文件保持可携带、跟着画布文件夹走
249
+ function assetMdPath(u) {
250
+ var m = String(u).match(/^\/api\/canvas\/file\?name=([^&]+)$/);
251
+ if (m) {
252
+ try {
253
+ var d = decodeURIComponent(m[1]);
254
+ if (d.indexOf("attachments/") === 0) return d;
255
+ } catch (_) {}
256
+ }
257
+ return u;
258
+ }
259
+ function w(n) {
260
+ if (n.nodeType === 3) return n.nodeValue.replace(/\u00a0/g, " ");
261
+ if (n.nodeType !== 1) return "";
262
+ // ⚠️ 脚注区域必须在 switch 之前拦截:.footnotes 是 <div>,
263
+ // 走 case "div" 会把它当普通正文输出,脚注定义就退化成
264
+ // "脚注 1 的内容 [↩](#fnref-1)" 这种半 markdown 的残渣。
265
+ if (n.classList && n.classList.contains('footnotes')) {
266
+ var lis = n.querySelectorAll('li');
267
+ for (var li2 = 0; li2 < lis.length; li2++) {
268
+ var liId = lis[li2].getAttribute('id') || '';
269
+ var liLabel = liId.indexOf('fn-') === 0 ? liId.slice(3) : '';
270
+ if (!liLabel) continue;
271
+ // 逐子节点序列化,跳过返回链接 ↩(href="#fnref-*"),保留其余行内格式
272
+ var parts = [];
273
+ var kids = lis[li2].childNodes || [];
274
+ for (var ki = 0; ki < kids.length; ki++) {
275
+ var kid = kids[ki];
276
+ if (kid.nodeType === 1) {
277
+ var kh = (kid.getAttribute && kid.getAttribute('href')) || '';
278
+ if (kh.indexOf('#fnref-') === 0) continue;
279
+ }
280
+ parts.push(w(kid));
281
+ }
282
+ var liContent = parts.join('').replace(/\s*↩\s*$/, '').trim();
283
+ fnDefs.push('[^' + liLabel + ']: ' + liContent);
284
+ }
285
+ return '';
286
+ }
287
+ var t = n.tagName ? n.tagName.toLowerCase() : "";
288
+ if (t === "br") return "\n";
289
+ var inner = Array.prototype.map.call(n.childNodes || [], w).join("");
290
+ switch (t) {
291
+ case "div": case "p": {
292
+ // 高亮块:mdToHtml 渲染 ::: highlight 时打的 class,序列化回围栏
293
+ if (t === "div" && n.classList && n.classList.contains("ccv-hl")) {
294
+ return withFence("highlight", inner);
295
+ }
296
+ return withAlign(blockAlign(n), inner + "\n");
297
+ }
298
+ case "h1": return withAlign(blockAlign(n), "# " + inner + "\n");
299
+ case "h2": return withAlign(blockAlign(n), "## " + inner + "\n");
300
+ case "h3": return withAlign(blockAlign(n), "### " + inner + "\n");
301
+ case "h4": return withAlign(blockAlign(n), "#### " + inner + "\n");
302
+ case "h5": return withAlign(blockAlign(n), "##### " + inner + "\n");
303
+ case "h6": return withAlign(blockAlign(n), "###### " + inner + "\n");
304
+ case "img": {
305
+ var iSrc = n.getAttribute && n.getAttribute("src") || "";
306
+ var iAlt = n.getAttribute && n.getAttribute("alt") || "";
307
+ return iSrc ? "![" + iAlt + "](" + assetMdPath(iSrc) + ")" : "";
308
+ }
309
+ case "ul": case "ol": return renderListMd(n, "");
310
+ case "li": {
311
+ var pN = n.parentNode;
312
+ var pOrdered = pN && pN.tagName && pN.tagName.toLowerCase() === "ol";
313
+ return serializeLi(n, pOrdered ? "1. " : "- ", "", pOrdered ? " " : " ");
314
+ }
315
+ case "strong": case "b": return inner ? "**" + inner + "**" : "";
316
+ case "em": case "i": return inner ? "*" + inner + "*" : "";
317
+ // markdown 没有下划线语法,用 ++文字++ 扩展标记存(渲染端 im 负责转回 <u>)。
318
+ // 之前直接存 <u> 原始标签,mdToHtml 的 esc 会把它转义成字面文字,重开画布就露馅
319
+ case "u": case "ins": return inner ? "++" + inner + "++" : "";
320
+ // 高亮:==文字== ↔ <mark>
321
+ case "mark": return inner ? "==" + inner + "==" : "";
322
+ case "strike": case "s": case "del": return inner ? "~~" + inner + "~~" : "";
323
+ case "code": return "`" + inner + "`";
324
+ case "pre": {
325
+ // 语言标记从 mdToHtml 打在 <pre data-lang> 上取回;没有就是裸围栏
326
+ var pLang = (n.getAttribute && n.getAttribute("data-lang")) || "";
327
+ return "\n```" + pLang + "\n" + (n.textContent || "") + "\n```\n";
328
+ }
329
+ case "blockquote": return inner.trim() ? inner.trim().split("\n").map(l => "> " + l).join("\n") + "\n" : "";
330
+ case "table": {
331
+ // <table> 必须显式序列化成 markdown 表格;走 default 会把单元格文本
332
+ // 压成一坨(含 ` 的 code 单元格混在一起),重新打开就没格式了
333
+ var tblTrs = n.querySelectorAll ? n.querySelectorAll("tr") : [];
334
+ if (!tblTrs.length) return "";
335
+ var tblRows = [], tblW = 0;
336
+ for (var trI = 0; trI < tblTrs.length; trI++) {
337
+ var tds = tblTrs[trI].querySelectorAll("th,td");
338
+ if (!tds.length) continue;
339
+ var rowArr = [];
340
+ for (var tdI = 0; tdI < tds.length; tdI++) {
341
+ var cellMd = Array.prototype.map.call(tds[tdI].childNodes || [], w).join("");
342
+ cellMd = cellMd.replace(/\s*\n\s*/g, " ").replace(/\|/g, "\\|").trim();
343
+ rowArr.push(cellMd);
344
+ }
345
+ if (rowArr.length > tblW) tblW = rowArr.length;
346
+ tblRows.push(rowArr);
347
+ }
348
+ if (!tblRows.length) return "";
349
+ var tblOut = [];
350
+ for (var trJ = 0; trJ < tblRows.length; trJ++) {
351
+ while (tblRows[trJ].length < tblW) tblRows[trJ].push("");
352
+ tblOut.push("| " + tblRows[trJ].join(" | ") + " |");
353
+ if (trJ === 0) {
354
+ var sepArr = [];
355
+ for (var scI = 0; scI < tblW; scI++) sepArr.push("---");
356
+ tblOut.push("| " + sepArr.join(" | ") + " |");
357
+ }
358
+ }
359
+ return "\n" + tblOut.join("\n") + "\n";
360
+ }
361
+ case "a":
362
+ var href = n.getAttribute && n.getAttribute("href") || "";
363
+ // 脚注引用链接:<a href="#fn-label">...</a> → [^label]
364
+ if (href.indexOf("#fn-") === 0) {
365
+ return "[^" + href.replace("#fn-", "") + "]";
366
+ }
367
+ return "[" + inner + "](" + assetMdPath(href) + ")";
368
+ case "hr": return "\n---\n";
369
+ case "sup":
370
+ // 脚注引用:<sup><a href="#fn-label">[label]</a></sup>
371
+ var aTag = n.querySelector ? n.querySelector('a[href^="#fn-"]') : null;
372
+ if (!aTag) {
373
+ // 兼容:遍历子节点找 a
374
+ var children = Array.prototype.slice.call(n.childNodes);
375
+ for (var ci = 0; ci < children.length; ci++) {
376
+ if (children[ci].nodeType === 1 && children[ci].tagName.toLowerCase() === 'a') {
377
+ var h = children[ci].getAttribute('href') || '';
378
+ if (h.indexOf('#fn-') === 0) { aTag = children[ci]; break; }
379
+ }
380
+ }
381
+ }
382
+ if (aTag) {
383
+ var href2 = aTag.getAttribute('href') || '';
384
+ var label = href2.replace('#fn-', '');
385
+ return '[^' + label + ']';
386
+ }
387
+ return inner;
388
+ default:
389
+ // 脚注引用 span:<span id="fnref-label">...</span> → [^label]
390
+ if (n.id && n.id.indexOf("fnref-") === 0) {
391
+ return "[^" + n.id.replace("fnref-", "") + "]";
392
+ }
393
+ return inner;
394
+ }
395
+ }
396
+ // 列表递归序列化:把 <ul>/<ol>(含嵌套子列表、任务列表复选框)还原成带缩进的 Markdown
397
+ function renderListMd(node, pad) {
398
+ var isOl = node.tagName.toLowerCase() === "ol";
399
+ var unit = isOl ? " " : " ";
400
+ var childPad = pad + unit;
401
+ var liOut = [];
402
+ var liNum = 0;
403
+ var kids = node.childNodes || [];
404
+ for (var x = 0; x < kids.length; x++) {
405
+ var el = kids[x];
406
+ if (!el || el.nodeType !== 1) continue;
407
+ var lt = el.tagName.toLowerCase();
408
+ if (lt === "ul" || lt === "ol") {
409
+ // 子列表作为兄弟节点(contentEditable 偶尔这样存)→ 整体缩进并入上一级
410
+ var sub = renderListMd(el, childPad).replace(/^\n+/, "").replace(/\n+$/, "");
411
+ if (sub) liOut.push(sub);
412
+ continue;
413
+ }
414
+ if (lt !== "li") continue;
415
+ liNum++;
416
+ var marker = isOl ? (liNum + ". ") : "- ";
417
+ liOut.push(serializeLi(el, marker, pad, childPad));
418
+ }
419
+ if (!liOut.length) return "";
420
+ return "\n" + liOut.join("\n") + "\n";
421
+ }
422
+ function serializeLi(liEl, marker, pad, childPad) {
423
+ // 任务列表:<li> 内含 <input type="checkbox"> → 还原成 - [ ] / - [x]
424
+ var cb = liEl.querySelector ? liEl.querySelector('input[type="checkbox"],input[type=checkbox]') : null;
425
+ var isTask = !!cb;
426
+ var checked = isTask && (cb.hasAttribute("checked") || cb.checked);
427
+ var parts = [];
428
+ var kids = liEl.childNodes || [];
429
+ for (var k = 0; k < kids.length; k++) {
430
+ var kid = kids[k];
431
+ if (kid.nodeType === 1) {
432
+ var kn = kid.tagName.toLowerCase();
433
+ if (kn === "input") continue; // 跳过复选框本身
434
+ if (kn === "ul" || kn === "ol") {
435
+ parts.push("\n" + renderListMd(kid, childPad).replace(/^\n+/, "").replace(/\n+$/, ""));
436
+ continue;
437
+ }
438
+ }
439
+ parts.push(w(kid));
440
+ }
441
+ var inner = parts.join("").replace(/^\n+/, "").replace(/\n+$/, "");
442
+ // 任务列表:mdToHtml 渲染成 `<input …> 内容`,跳过 input 后那个前导空格
443
+ // 会留在文本节点里,拼出来就是 "- [ ] 内容"(两个空格)。这里吃掉它。
444
+ if (isTask) inner = inner.replace(/^[ \t]+/, "");
445
+ var lns = inner.split("\n");
446
+ var out = pad + (isTask ? ("- [" + (checked ? "x" : " ") + "] ") : marker) + lns[0];
447
+ for (var j = 1; j < lns.length; j++) {
448
+ var ln = lns[j];
449
+ // ⚠️ 嵌套列表已经由 renderListMd(kid, childPad) 按 childPad 缩进过了,
450
+ // 这里再加一次 pad 会变成双倍缩进(三级列表 4 空格漂成 6 空格)。
451
+ // 判据:已带前导空白 = 自己管好了缩进,直接原样输出;空行保持空。
452
+ out += "\n" + (ln === "" ? "" : (/^[ \t]/.test(ln) ? ln : pad + ln));
453
+ }
454
+ return out;
455
+ }
456
+ var md = w(root).replace(/\n{3,}/g, "\n\n").trim();
457
+ // 追加脚注定义
458
+ if (fnDefs.length > 0) {
459
+ md += '\n\n' + fnDefs.join('\n');
460
+ }
461
+ return md;
462
+ }
463
+
464
+ function mdToHtml(src) {
465
+ if (!src) return "";
466
+ function esc(s) { return String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;"); }
467
+ // 附件相对路径(attachments/…)重写成 host 读文件接口;编辑器里才能显示。
468
+ // 存盘的 md 始终保持相对路径,画布文件夹整个挪走/换机器都不断链
469
+ function resolveAssetUrl(u) {
470
+ if (/^attachments\//.test(u)) return "/api/canvas/file?name=" + encodeURIComponent(u)
471
+ return u
472
+ }
473
+ function im(s) {
474
+ // ⚠️ 行内代码必须「先抽出、后还原」。
475
+ // 代码跨度里的 * ~ = + 等字符不是标记。若先把代码换成 <code> HTML、
476
+ // 再跑强调规则,`\*([^*]+)\*` 会跨过 <code> 边界配对,生成非法嵌套
477
+ // (<em>…<code>…</em>…</code>);浏览器纠正该 DOM 后,htmlToMd 再把它
478
+ // 序列化回 markdown,反引号位置就错乱了 —— 文档被静默破坏。
479
+ // 实例:**移除全部 `emit(EV.*)` 调用** 会变成 **移除全部 `emit(EV.`*`)` 调用**。
480
+ // 做法:先把代码替换成不含标记字符的占位符,跑完全部行内规则再还原。
481
+ var codes = []
482
+ var body = esc(s).replace(/`([^`]+)`/g, function (_mc, code) {
483
+ codes.push(code)
484
+ return '\u0000C' + (codes.length - 1) + '\u0000'
485
+ })
486
+ var out = body
487
+ // 兼容旧版直接存进 md 的 <u> 原始标签(已被 esc 转义成 &lt;u&gt;)→ 转回真下划线
488
+ .replace(/&lt;u&gt;([\s\S]*?)&lt;\/u&gt;/g, '<u>$1</u>')
489
+ // 兼容旧版 htmlToMd 产生的 [[label]](#fn-label) 手写链接 → 转成规范脚注引用
490
+ .replace(/\[\[([^\]]+)\]\]\(#fn-([^)]+)\)/g, '<a href="#fn-$2" id="fnref-$2" contenteditable="false" style="vertical-align:super;font-size:.75em;color:#3b82f6;cursor:pointer;text-decoration:none">[$2]</a>')
491
+ // ⚠️ 图片必须先于链接规则处理:!\[alt\](src) 里的 [alt](src) 会被链接
492
+ // 规则啃掉,渲染成 !<a href="src">alt</a> 这种残骸
493
+ .replace(/!\[([^\]]*)\]\(([^)]+)\)/g, function (_m0, alt, src) {
494
+ return '<img src="' + resolveAssetUrl(src) + '" alt="' + alt + '" style="max-width:100%;height:auto;vertical-align:middle"/>'
495
+ })
496
+ .replace(/\[([^\]]+)\]\(([^)]+)\)/g, function (_m1, txt, href) {
497
+ // title 悬停显示 md 里写的原始地址;esc 不管引号,title 属性里自己补 &quot;
498
+ return '<a href="' + resolveAssetUrl(href) + '" title="' + String(href).replace(/"/g, '&quot;') + '">' + txt + '</a>'
499
+ })
500
+ // 注:代码跨度已在上面抽走,这里的 * 只可能是真标记,不会跨界误配对
501
+ .replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
502
+ .replace(/\*([^*]+)\*/g, "<em>$1</em>")
503
+ .replace(/~~([^~]+)~~/g, "<del>$1</del>")
504
+ .replace(/\+\+([^+]+)\+\+/g, "<u>$1</u>")
505
+ .replace(/==([^=]+)==/g, "<mark>$1</mark>")
506
+ .replace(/\[\^([^\]]+)\](?!:)/g, '<a href="#fn-$1" id="fnref-$1" contenteditable="false" style="vertical-align:super;font-size:.75em;color:#3b82f6;cursor:pointer;text-decoration:none">[$1]</a>')
507
+ // 还原代码跨度(内容在 esc 阶段已转义,直接放回)
508
+ return out.replace(/\u0000C(\d+)\u0000/g, function (_mr, idx) {
509
+ return '<code>' + codes[Number(idx)] + '</code>'
510
+ })
511
+ }
512
+ // 块级渲染(可递归:blockquote 内部再走一遍同样的块解析,支持引用里套表格/列表)
513
+ function buildTable(ls) {
514
+ var rows = [];
515
+ for (var k = 0; k < ls.length; k++) {
516
+ if (k === 1) continue; // 第二行是 |---|---| 分隔行
517
+ var raw = ls[k].trim().replace(/^\|/, "").replace(/\|$/, "");
518
+ var cells = raw.split("|").map(function (c) { return im(c.trim()); });
519
+ rows.push(cells);
520
+ }
521
+ var tbl = "<table>";
522
+ for (var k2 = 0; k2 < rows.length; k2++) {
523
+ var tag = k2 === 0 ? "th" : "td";
524
+ tbl += "<tr>" + rows[k2].map(function (c) { return "<" + tag + ">" + c + "</" + tag + ">"; }).join("") + "</tr>";
525
+ }
526
+ return tbl + "</table>";
527
+ }
528
+ function renderBlocks(src2) {
529
+ var lines = String(src2).split("\n"), out = [], i = 0, inCode = false, codeBuf = [];
530
+ // 开围栏那一行本身不进 codeBuf,但它尾巴上的语言标记(```js)必须留下 ——
531
+ // 否则往返一次语言就没了(htmlToMd 只能输出裸围栏)。
532
+ // 存在 <pre data-lang> 上,htmlToMd 的 case "pre" 再读回来。
533
+ var codeLang = "";
534
+ while (i < lines.length) {
535
+ var l = lines[i];
536
+ if (/^```/.test(l)) {
537
+ if (!inCode) { inCode = true; codeBuf = []; codeLang = String(l).replace(/^```/, "").trim(); }
538
+ else { out.push("<pre" + (codeLang ? ' data-lang="' + esc(codeLang) + '"' : "") + "><code>" + esc(codeBuf.join("\n")) + "</code></pre>"); inCode = false; codeLang = ""; }
539
+ i++; continue;
540
+ }
541
+ if (inCode) { codeBuf.push(l); i++; continue; }
542
+ var t = l.trim(); if (!t) { i++; continue; }
543
+ var h2 = t.match(/^(#{1,6})\s+(.*)$/); if (h2) { out.push("<h" + h2[1].length + ">" + im(h2[2]) + "</h" + h2[1].length + ">"); i++; continue; }
544
+ if (/^(-{3,}|\*{3,})$/.test(t)) { out.push("<hr/>"); i++; continue; }
545
+ // ::: 围栏:center/right/left 对齐 + highlight 高亮块,同一套机制,
546
+ // 支持嵌套(depth 计数),内部递归渲染,标题/列表/引用放进去都行
547
+ var alM = t.match(/^:::\s*(center|right|left|highlight)\s*$/i);
548
+ if (alM) {
549
+ var alInner = [], alDepth = 1;
550
+ i++;
551
+ while (i < lines.length) {
552
+ var alT = lines[i].trim();
553
+ if (/^:::\s*$/.test(alT)) { alDepth--; if (!alDepth) { i++; break; } }
554
+ else if (/^:::\s*(center|right|left|highlight)\s*$/i.test(alT)) alDepth++;
555
+ alInner.push(lines[i]); i++;
556
+ }
557
+ var alKind = alM[1].toLowerCase();
558
+ out.push(alKind === "highlight"
559
+ ? '<div class="ccv-hl">' + renderBlocks(alInner.join("\n")) + "</div>"
560
+ : '<div style="text-align:' + alKind + '">' + renderBlocks(alInner.join("\n")) + "</div>");
561
+ continue;
562
+ }
563
+ // 表格:本行含 |,且下一行是 |---|---| 形式的分隔行 → 收集连续 | 行成表
564
+ if (t.indexOf("|") >= 0 && i + 1 < lines.length) {
565
+ var next = lines[i + 1].trim();
566
+ if (next.indexOf("|") >= 0 && next.indexOf("-") >= 0 && /^\|?[\s:|-]+\|?$/.test(next)) {
567
+ var tblLines = [t];
568
+ // ⚠️ 只 i+=1:分隔行也要进 tblLines(buildTable 靠 ls[1] 是分隔行来跳过它),
569
+ // 若在这里 i+=2 跳过分隔行,第一行正文会被 buildTable 当分隔行吃掉
570
+ i += 1;
571
+ while (i < lines.length && lines[i].trim() && lines[i].indexOf("|") >= 0) { tblLines.push(lines[i].trim()); i++; }
572
+ out.push(buildTable(tblLines));
573
+ continue;
574
+ }
575
+ }
576
+ // 引用:连续 > 行合并为一个 blockquote,内部递归块渲染
577
+ if (/^>\s?/.test(t)) {
578
+ var bqLines = [];
579
+ while (i < lines.length && /^\s*>\s?/.test(lines[i])) { bqLines.push(lines[i].replace(/^\s*>\s?/, "")); i++; }
580
+ out.push("<blockquote>" + renderBlocks(bqLines.join("\n")) + "</blockquote>");
581
+ continue;
582
+ }
583
+ // 列表(支持嵌套缩进 + 任务列表 - [ ]/- [x]):连续列表项(含缩进子项)合成嵌套 <ul>/<ol>
584
+ if (/^\s*[-*+]\s+/.test(t) || /^\s*\d+[.)]\s+/.test(t)) {
585
+ var listLines = [];
586
+ while (i < lines.length) {
587
+ var lm = lines[i].match(/^(\s*)([-*+]|\d+[.)])\s+(.*)$/);
588
+ if (lm) { listLines.push(lines[i]); i++; continue; }
589
+ break;
590
+ }
591
+ out.push(parseList(listLines));
592
+ continue;
593
+ }
594
+ out.push("<p>" + im(t) + "</p>"); i++;
595
+ }
596
+ if (inCode) out.push("<pre" + (codeLang ? ' data-lang="' + esc(codeLang) + '"' : "") + "><code>" + esc(codeBuf.join("\n")) + "</code></pre>");
597
+ return out.join("\n");
598
+ }
599
+ // 列表解析:把一组列表行(含缩进子项)解析成嵌套树,再渲染成 <ul>/<ol>。
600
+ // 支持 - * + 无序、1. 有序、以及任务列表 - [ ] / - [x](渲染成复选框)。
601
+ function parseList(listLines) {
602
+ var items = [];
603
+ for (var k = 0; k < listLines.length; k++) {
604
+ var m = listLines[k].match(/^(\s*)([-*+]|\d+[.)])\s+(.*)$/);
605
+ if (!m) continue;
606
+ var indent = m[1].replace(/\t/g, " ").length;
607
+ items.push({ indent: indent, ordered: /\d/.test(m[2]), content: m[3] });
608
+ }
609
+ if (!items.length) return "";
610
+ // 栈式嵌套:indent 严格大于栈顶才作为子项
611
+ var root = { children: [] };
612
+ var stack = [{ indent: -1, node: root }];
613
+ for (var a = 0; a < items.length; a++) {
614
+ var it = items[a];
615
+ while (stack.length > 1 && it.indent <= stack[stack.length - 1].indent) stack.pop();
616
+ var parent = stack[stack.length - 1].node;
617
+ var node = { ordered: it.ordered, content: it.content, children: [] };
618
+ parent.children.push(node);
619
+ stack.push({ indent: it.indent, node: node });
620
+ }
621
+ function renderNodes(ns) {
622
+ if (!ns.length) return "";
623
+ var tag = ns[0].ordered ? "ol" : "ul";
624
+ var html = "<" + tag + ">";
625
+ for (var i = 0; i < ns.length; i++) html += "<li>" + renderItem(ns[i]) + "</li>";
626
+ return html + "</" + tag + ">";
627
+ }
628
+ function renderItem(n) {
629
+ var inner = renderItemContent(n.content);
630
+ if (n.children && n.children.length) inner += renderNodes(n.children);
631
+ return inner;
632
+ }
633
+ function renderItemContent(content) {
634
+ var tm = content.match(/^\[([ xX])\]\s+(.*)$/);
635
+ if (tm) {
636
+ var ck = tm[1].toLowerCase() === "x" ? " checked" : "";
637
+ return '<input type="checkbox"' + ck + '> ' + im(tm[2]);
638
+ }
639
+ return im(content);
640
+ }
641
+ return renderNodes(root.children);
642
+ }
643
+ // 第一遍:收集脚注定义([^label]: 内容),不进正文
644
+ var lines0 = String(src).split("\n");
645
+ var footnotes = {}, mainLines = [];
646
+ var i0 = 0;
647
+ while (i0 < lines0.length) {
648
+ var l0 = lines0[i0];
649
+ var fnDef = l0.match(/^\[\^([^\]]+)\]:\s*(.*)$/);
650
+ if (fnDef) {
651
+ var label0 = fnDef[1], content0 = fnDef[2];
652
+ // 多行脚注定义:后续缩进行属于同一脚注
653
+ while (i0 + 1 < lines0.length && /^\s+/.test(lines0[i0 + 1]) && !/^\[\^/.test(lines0[i0 + 1].trim())) {
654
+ i0++;
655
+ content0 += " " + lines0[i0].trim();
656
+ }
657
+ footnotes[label0] = content0;
658
+ i0++;
659
+ continue;
660
+ }
661
+ mainLines.push(l0);
662
+ i0++;
663
+ }
664
+ var bodyHtml = renderBlocks(mainLines.join("\n"));
665
+ // 渲染脚注区域
666
+ var fnKeys = Object.keys(footnotes);
667
+ if (fnKeys.length > 0) {
668
+ var fnOut = ['<div class="footnotes"><hr/><ol>'];
669
+ fnKeys.forEach(function(label, idx) {
670
+ fnOut.push('<li id="fn-' + label + '">' + im(footnotes[label]) + ' <a href="#fnref-' + label + '" contenteditable="false">↩</a></li>');
671
+ });
672
+ fnOut.push('</ol></div>');
673
+ bodyHtml += "\n" + fnOut.join("\n");
674
+ }
675
+ return bodyHtml;
676
+ }
677
+
678
+ // ─── 静态资源(模块级)─────────────────────────────
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
+ }, []);
795
+
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
+ }, []);
1121
+
1122
+ // 把编辑器里**还没写出去**的内容立刻落一次。返回 Promise:
1123
+ // resolve(null) 没有待写内容(没编辑器 / 内容与服务端一致)
1124
+ // resolve({ ok:true,… }) 写成功
1125
+ // resolve({ conflict:… }) 服务端版本已前进,本机这一份没写进去
1126
+ //
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));
1276
+ }
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); });
1284
+ }
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("读取登记信息失败"));
1314
+ }
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(); });
1332
+ }
1333
+ function toggleDrop() { setCreating(false); setDropOpen(v => !v); }
1334
+
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();
1343
+ }
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() })
1381
+ }
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
1398
+ }
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)
1413
+ }
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
1428
+ }
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)
1444
+ })
1445
+ dd.appendChild(rr)
1446
+ return
1447
+ }
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) }
1463
+ })
1464
+ }, 0)
1465
+ }
1466
+
1467
+ function showDropdown(anchor, items, onSelect) {
1468
+ var rect = anchor.getBoundingClientRect()
1469
+ openDropdownAt(rect.left, rect.bottom + 4, items, onSelect)
1470
+ }
1471
+
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
+ }
1575
+
1576
+ // 问 AI:选中文本以引用块格式塞进 DSH 输入框(划词栏💬与右键菜单共用)
1577
+ function askAiFromSelection() {
1578
+ var sel = document.getSelection()
1579
+ var text = sel ? sel.toString().trim() : ''
1580
+ if (!text) { alert('请先选中要问 AI 的文本'); return }
1581
+
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
+
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 (_) {} }
1608
+
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
+ }
1617
+ }
1618
+
1619
+ // 输入框里现有几条「引用胶囊」。
1620
+ // ⚠️ 判断方式说明:官方把草稿以**纯文字投影**给出(胶囊摊成 `@名字 > 说明`),
1621
+ // 所以光看草稿文字是分不出"胶囊"和"用户手打的 @"的,得看胶囊清单。
1622
+ function ccvInputChipCount() {
1623
+ 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()
1654
+ return
1655
+ }
1656
+
1657
+ // ① 首选:请侧模块走官方「插入纯文字」事件 —— 零宽、只往末尾追加,
1658
+ // 已插进去的胶囊一个都不动。
1659
+ 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")
1818
+ }
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
1834
+ }
1835
+ if (editorRef.current) editorRef.current.focus()
1836
+ return true
1837
+ }
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
+ })
1881
+ }
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
+ })
1901
+ }
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
+ })
1915
+ }
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
+ })
1949
+ }
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() }
1969
+ }
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(_){}
1986
+ }
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
+ })
2062
+ }
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
+ })
2073
+ }
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; }
2080
+ }
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"; }
2089
+ },
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
+ }
2189
+
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
+ }
2210
+
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)"
2224
+ }
2225
+ }, h(CanvasPanel, { ctx: ccvCtx }));
2226
+ }
2227
+
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
+
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);
2248
+ }
2249
+
2250
+ function apply(ctx) {
2251
+ ccvCtx = ctx; // tab 正文是另一个组件,用的就是这个引用
2252
+
2253
+ // ─── 侧模块桥接:把根上下文挂到 window ────────────────────
2254
+ // 侧模块(docdrop / doclink / selref)由 new Function(src)() 执行,
2255
+ // 作用域是全局,拿不到这里的任何局部变量 —— 只能走 window 桥。
2256
+ // 握手是双向的:apply 与侧模块加载的先后顺序不固定(两边都异步),
2257
+ // · 这里先跑 → 直接回调侧模块挂出的 boot
2258
+ // · 侧模块先跑 → 它自己会看到 __ccvCtx 并自举
2259
+ // 细节见 lib/selref.js 顶部说明。
2260
+ try {
2261
+ window.__ccvCtx = ctx;
2262
+ if (typeof window.__ccvSelRefBoot === 'function') window.__ccvSelRefBoot(ctx);
2263
+ } catch (e) { ccvLog('侧模块桥接失败: ' + (e && e.message)); }
2264
+ // ─── 会话内容限流 ──────────────────────────────
2265
+ // DSH 的会话消息不主动给 <img> 设 max-width,AI 生成的大图 / 截图会撑破
2266
+ // 会话列宽;右侧一旦打开话布(position:fixed、高 z-index),溢出的图片
2267
+ // 就被压在话布底下看不见。在 apply 入口注入一次,按列宽缩放。
2268
+ ensureStyle('ccv-cc-constrain',
2269
+
2270
+ // 全局兜底:DSH 各处都可能塞大图(AI 截图、代码块配图、Markdown 预览、
2271
+ // React Portal 灯箱等),都得按父容器宽缩放。图标通常有自己的显式
2272
+ // width/height,加这两条只对它们是"上限",不影响已设的尺寸。
2273
+ // ⚠️ 不加 height:auto——只显式 width 没显式 height 的图标会被重置比例。
2274
+ 'img{' +
2275
+ 'max-width:100%!important;' +
2276
+ 'max-height:100vh!important;' +
2277
+ '}' +
2278
+ // 会话列:图片按列宽缩放,长代码块横向滚动
2279
+ '[data-slot="conversation"] img,' +
2280
+ '[data-pane="conversation"] img,' +
2281
+ // 话布面板内部:话布渲染的内容里有 AI 给的 <img>,同样会撑破面板
2282
+ // 用 .ccv-panel(面板根自己的 class)而不是 #collab-canvas-panel:
2283
+ // 停靠到右侧栏时没有那个宿主 id,写死 id 会让这一整段失效。
2284
+ // 类名写两遍是故意加权重,替掉原来靠 id 提权的做法。
2285
+ '.ccv-panel.ccv-panel img,' +
2286
+ '.ccv-panel.ccv-panel .ccv-editor img{' +
2287
+ 'max-width:100%!important;' +
2288
+ '}' +
2289
+ // 不让 #root / body / 浮层超出视口横向滚动
2290
+ 'html,body,#root{' +
2291
+ 'overflow-x:hidden!important;' +
2292
+ '}' +
2293
+ '[data-slot="conversation"] pre,' +
2294
+ '[data-pane="conversation"] pre,' +
2295
+ // 长 <pre> 也按列宽走横向滚动,避免撑破话布面板
2296
+ '.ccv-panel.ccv-panel .ccv-editor pre{' +
2297
+ 'max-width:100%!important;' +
2298
+ 'overflow-x:auto!important;' +
2299
+ '}'
2300
+ );
2301
+ // ─── 两行顶栏样式 ────────────────────────────
2302
+ // 上行管话布(+新建 / 文件名切换 / 全屏),下行管当前文档(保存 / 重载 / 关闭)。
2303
+ // hover 与 disabled 用内联 style 表达不了(内联优先级还会盖掉 CSS 的 hover),
2304
+ // 所以走 className + 注入 CSS;选择器统一带 .ccv-panel 前缀提权(面板根自己的
2305
+ // class,悬浮/停靠两种摆法都命中),因此不需要 !important。
2306
+ ensureStyle('ccv-hdr2-style',
2307
+ [
2308
+ '.ccv-panel .ccv-head{display:flex;flex-direction:column;flex:0 0 auto;min-width:0;border-bottom:1px solid var(--dsw-alias-border-l1,rgba(128,128,128,.25))}',
2309
+ '.ccv-panel .ccv-row{display:flex;align-items:center;gap:6px;padding:5px 8px;flex-wrap:nowrap;overflow:visible;min-width:0}',
2310
+ '.ccv-panel .ccv-row-top{position:relative}',
2311
+ '.ccv-panel .ccv-row-doc{border-top:1px solid var(--dsw-alias-border-l1,rgba(128,128,128,.14))}',
2312
+ '.ccv-panel .ccv-ibtn{width:26px;height:26px;display:flex;align-items:center;justify-content:center;background:transparent;border:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.3));border-radius:6px;color:inherit;cursor:pointer;font-size:13px;font-family:inherit;line-height:1;padding:0;flex:0 0 auto;white-space:nowrap}',
2313
+ '.ccv-panel .ccv-ibtn:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(128,128,128,.15))}',
2314
+ '.ccv-panel .ccv-ibtn:disabled{opacity:.35;cursor:default}',
2315
+ '.ccv-panel .ccv-ibtn:disabled:hover{background:transparent}',
2316
+ '.ccv-panel .ccv-tbtn{padding:4px 9px;border:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.3));border-radius:6px;background:transparent;color:inherit;font-size:12px;font-family:inherit;cursor:pointer;flex:0 0 auto;white-space:nowrap;line-height:1.4}',
2317
+ '.ccv-panel .ccv-tbtn:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(128,128,128,.15))}',
2318
+ '.ccv-panel .ccv-tbtn:disabled{opacity:.35;cursor:default}',
2319
+ '.ccv-panel .ccv-tbtn:disabled:hover{background:transparent}',
2320
+ '.ccv-panel .ccv-chip{display:flex;align-items:center;gap:6px;padding:4px 6px 4px 8px;background:rgba(128,128,128,.08);border:1px solid var(--dsw-alias-border-l1,rgba(128,128,128,.3));border-radius:7px;max-width:240px;min-width:56px;flex:0 1 auto;font-size:13px;color:inherit;cursor:pointer;user-select:none}',
2321
+ '.ccv-panel .ccv-chip:hover{filter:brightness(1.06)}',
2322
+ '.ccv-panel .ccv-chipwrap{position:relative;display:flex;min-width:0;flex:0 1 auto}',
2323
+ '.ccv-panel .ccv-chip-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1 1 auto;min-width:0}',
2324
+ '.ccv-panel .ccv-chip-caret{flex:0 0 auto;font-size:10px;line-height:1;opacity:.65}',
2325
+ '.ccv-panel .ccv-spacer{flex:1 1 0%;min-width:6px}',
2326
+ '.ccv-panel .ccv-ninput{width:150px;flex:0 1 auto;min-width:64px;padding:4px 8px;border:1px solid var(--dsw-alias-border-l1,rgba(128,128,128,.35));border-radius:6px;background:transparent;color:inherit;font-size:13px;font-family:inherit;outline:none;line-height:1.4}',
2327
+ '.ccv-panel .ccv-drop{position:absolute;top:calc(100% + 3px);left:0;z-index:60;min-width:200px;max-width:min(260px,80vw);max-height:52vh;overflow-y:auto;background:var(--dsw-alias-bg-layer-2,#fff);border:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.3));border-radius:8px;padding:4px;box-shadow:0 4px 14px rgba(0,0,0,.12)}',
2328
+ '.ccv-panel .ccv-drop-item{display:block;width:100%;text-align:left;padding:6px 8px;border:none;background:transparent;color:inherit;font-size:13px;font-family:inherit;cursor:pointer;border-radius:5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;box-sizing:border-box}',
2329
+ '.ccv-panel .ccv-drop-item:hover{background:rgba(128,128,128,.12)}',
2330
+ '.ccv-panel .ccv-drop-item[data-on="1"]{font-weight:600;background:rgba(128,128,128,.1)}',
2331
+ '.ccv-panel .ccv-drop-row{display:flex;align-items:center;gap:4px}',
2332
+ '.ccv-panel .ccv-drop-row .ccv-drop-item{flex:1 1 auto;min-width:0}',
2333
+ '.ccv-panel .ccv-drop-x{flex:0 0 auto;width:20px;height:24px;border:none;background:transparent;color:inherit;opacity:.45;cursor:pointer;font-size:14px;line-height:1;border-radius:4px}',
2334
+ '.ccv-panel .ccv-drop-x:hover{opacity:1;background:rgba(128,128,128,.18)}',
2335
+ '.ccv-panel .ccv-drop-empty{padding:8px;opacity:.6;font-size:12px;text-align:center}',
2336
+ '.ccv-panel .ccv-meter{font-size:11px;opacity:0;transition:opacity .15s ease;white-space:nowrap;user-select:none;font-variant-numeric:tabular-nums;flex:0 0 auto}',
2337
+ // 脚注区域样式
2338
+ '.ccv-panel .ccv-editor .footnotes{margin-top:16px;padding-top:8px;font-size:13px;color:var(--dsw-alias-label-secondary,rgba(128,128,128,.85))}',
2339
+ '.ccv-panel .ccv-editor .footnotes hr{border:none;border-top:1px solid var(--dsw-alias-border-l1,rgba(128,128,128,.25));margin:0 0 8px}',
2340
+ '.ccv-panel .ccv-editor .footnotes ol{margin:0;padding-left:20px}',
2341
+ '.ccv-panel .ccv-editor .footnotes li{margin:4px 0;line-height:1.6}',
2342
+ '.ccv-panel .ccv-editor .footnotes a{color:#3b82f6;text-decoration:none}'
2343
+ ].join('\n')
2344
+ );
2345
+ // ─── Markdown 渲染样式(新 editor 用 .ccv-editor;旧 03-styles.js 的 .ccv-wysiwyg 已弃用)
2346
+ ensureStyle('ccv-md-style',
2347
+ [
2348
+ '.ccv-panel .ccv-editor > *:first-child{margin-top:0}',
2349
+ '.ccv-panel .ccv-editor > *:last-child{margin-bottom:0}',
2350
+ '.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}',
2351
+ '.ccv-panel .ccv-editor h2{font-size:1.35em;margin:.7em 0 .45em;font-weight:600;line-height:1.35}',
2352
+ '.ccv-panel .ccv-editor h3{font-size:1.18em;margin:.7em 0 .45em;font-weight:600;line-height:1.35}',
2353
+ '.ccv-panel .ccv-editor h4{font-size:1.05em;margin:.7em 0 .45em;font-weight:600;line-height:1.35}',
2354
+ '.ccv-panel .ccv-editor h5{font-size:1em;margin:.7em 0 .45em;font-weight:600;line-height:1.35}',
2355
+ '.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
+ '.ccv-panel .ccv-editor p{margin:.5em 0}',
2357
+ '.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
+ '.ccv-panel .ccv-editor blockquote > *:first-child{margin-top:0}',
2359
+ '.ccv-panel .ccv-editor blockquote > *:last-child{margin-bottom:0}',
2360
+ '.ccv-panel .ccv-editor mark{background:rgba(250,204,21,.45);color:inherit;padding:0 2px;border-radius:3px}',
2361
+ '.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
+ '.ccv-panel .ccv-editor .ccv-hl > *:first-child{margin-top:0}',
2363
+ '.ccv-panel .ccv-editor .ccv-hl > *:last-child{margin-bottom:0}',
2364
+ '.ccv-panel .ccv-editor input[type="checkbox"]{cursor:pointer}',
2365
+ '.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
+ '.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}',
2367
+ '.ccv-panel .ccv-editor pre code{background:transparent;padding:0;border-radius:0}',
2368
+ '.ccv-panel .ccv-editor ul,.ccv-panel .ccv-editor ol{padding-left:1.6em;margin:.5em 0}',
2369
+ '.ccv-panel .ccv-editor li{margin:.25em 0}',
2370
+ '.ccv-panel .ccv-editor ul ul,.ccv-panel .ccv-editor ol ol,.ccv-panel .ccv-editor ul ol,.ccv-panel .ccv-editor ol ul{margin:.2em 0}',
2371
+ '.ccv-panel .ccv-editor li input[type="checkbox"]{margin-right:.45em;vertical-align:middle;width:1em;height:1em}',
2372
+ '.ccv-panel .ccv-editor li:has(> input[type="checkbox"]){list-style:none;margin-left:-.2em}',
2373
+ '.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
+ '.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
+ '.ccv-panel .ccv-editor th{background:var(--dsw-alias-interactive-bg-active,rgba(128,128,128,.18));font-weight:600}',
2376
+ '.ccv-panel .ccv-editor tr:nth-child(even){background:var(--dsw-alias-interactive-bg-hover,rgba(128,128,128,.08))}',
2377
+ '.ccv-panel .ccv-editor tr:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(59,130,246,.08))}',
2378
+ '.ccv-panel .ccv-editor hr{border:none;border-top:1px solid var(--dsw-alias-border-l1,rgba(128,128,128,.3));margin:1em 0}',
2379
+ '.ccv-panel .ccv-editor a{color:#3b82f6;text-decoration:underline;cursor:pointer}',
2380
+ // 脚注引用 [1] 和文末 ↩ 不是网页链接,保持无下划线、不做手型
2381
+ '.ccv-panel .ccv-editor a[href^="#fn"]{text-decoration:none;cursor:default}',
2382
+ '.ccv-panel .ccv-editor img{max-width:100%;height:auto;vertical-align:middle}',
2383
+ '.ccv-panel .ccv-editor strong{font-weight:600}',
2384
+ '.ccv-panel .ccv-editor em{font-style:italic}',
2385
+ '.ccv-panel .ccv-editor del{text-decoration:line-through;opacity:.75}'
2386
+ ].join('\n')
2387
+ );
2388
+ // ─── JS 终极兜底 ──────────────────────────────
2389
+ // 即使 CSS 选择器漏、即使 inline style 用了 !important (CSS 覆盖不了
2390
+ // inline !important) 都能压住。
2391
+ //
2392
+ // ⚠️ 2026-09-12 改:**不再"任何 DOM 新增都全量重扫"**。
2393
+ // 实测(600 次 DOM 变更,模拟 AI 流式输出):旧写法每次变更都重扫全页图片,
2394
+ // 4.0ms → 93.5ms,每次多花 0.155ms、**慢 233 倍**。
2395
+ // (同一个函数在"布局已静止"的页面上只要 0.027ms —— 差别全在布局是不是脏的:
2396
+ // 布局一旦变脏,每次读 offsetWidth 都会强制同步重排。所以静态页面测不出问题。)
2397
+ // 现在:DOM 变化只在"新增节点里确实含 <img>"时才扫,且**只扫新增的那棵子树**;
2398
+ // 另外保留每秒一次的全量扫作为安全网 —— 它专门负责"容器变窄但没加节点"这类情况
2399
+ // (那种情况不产生任何 DOM 变化,观察者根本收不到通知)。
2400
+ // 安全网的代价:静止时每次约 0.03ms,流式时每秒约 0.15ms,都不值一提。
2401
+ //
2402
+ // ⚠️ 2026-09-12 又修两处(真机实测才发现,前面几轮静态检查全都没抓到):
2403
+ // ① `addEventListener("DOMContentLoaded", fix)` 会把**事件对象**当第一个实参传进去,
2404
+ // 于是 fix 收到 root=Event(不是节点),`scope.querySelectorAll` 不存在 → 扫空集合。
2405
+ // 结果:首次注入时那次修复是**哑的**,只能等 1 秒后的定时器。
2406
+ // 实测时间线:图片 2000px 溢出,DOMContentLoaded 不动,1200ms 才被压回。
2407
+ // 改成 `function(){fix()}` 后,DOMContentLoaded 当帧就压回 —— 提前约 1.2 秒。
2408
+ // ② 图片还没解码完时 `offsetWidth`/`naturalWidth` 都是 0,量不出溢出,
2409
+ // 于是给它挂一次性 `load` 回调,解码完再量一次(属性 `__ccvLh` 防重复挂)。
2410
+ // 这条对**网络图片**才是关键(data URI 是同步解码的,测不出来)。
2411
+ // 真机三场景回归(初始 / 容器变窄 / 后续新插入)全部 100% 压住。
2412
+ //
2413
+ // ⚠️ JS 必须严格无 inline `//` 注释——下面的代码会被拼成单行,注释到 EOF
2414
+ // 行为不可靠。这里用 \n 转义序列替代源换行,并把注释放进函数体内作为可
2415
+ // 运行的字符串字面量(不是真的 JS 注释,零运行时开销)。
2416
+ if (!document.getElementById('ccv-cc-constrain-js')) {
2417
+ try {
2418
+ var js = document.createElement('script');
2419
+ js.id = 'ccv-cc-constrain-js';
2420
+ js.textContent = [
2421
+ '(function(){',
2422
+ 'var one=function(img){',
2423
+ 'if(!img||!img.isConnected)return;',
2424
+ 'var parent=img.parentElement||document.body;',
2425
+ 'var pw=parent.clientWidth;',
2426
+ 'if(!pw)return;',
2427
+ 'var w=img.offsetWidth||img.naturalWidth||0;',
2428
+ 'if(!w){if(!img.__ccvLh){img.__ccvLh=1;img.addEventListener("load",function(){one(img)},false);}return;}',
2429
+ 'if(w>pw+2){',
2430
+ 'img.style.maxWidth="100%";',
2431
+ 'img.style.maxHeight="100vh";',
2432
+ 'img.style.width="100%";',
2433
+ 'img.style.height="auto";',
2434
+ '}',
2435
+ '};',
2436
+ 'var fix=function(root){',
2437
+ 'var scope=(root&&root.nodeType)?root:document;',
2438
+ 'if(scope.nodeType===1&&scope.tagName==="IMG"){one(scope);return;}',
2439
+ 'var imgs=scope.querySelectorAll?scope.querySelectorAll("img"):[];',
2440
+ 'for(var i=0;i<imgs.length;i++)one(imgs[i]);',
2441
+ '};',
2442
+ 'var hasImg=function(n){',
2443
+ 'if(!n||n.nodeType!==1)return false;',
2444
+ 'if(n.tagName==="IMG")return true;',
2445
+ 'return !!(n.querySelector&&n.querySelector("img"));',
2446
+ '};',
2447
+ 'if(document.readyState!=="loading")fix();',
2448
+ 'else document.addEventListener("DOMContentLoaded",function(){fix()});',
2449
+ 'setInterval(fix,1000);',
2450
+ 'try{',
2451
+ 'new MutationObserver(function(muts){',
2452
+ 'var hit=null;',
2453
+ 'for(var i=0;i<muts.length;i++){',
2454
+ 'var ns=muts[i].addedNodes;',
2455
+ 'if(!ns||!ns.length)continue;',
2456
+ 'for(var j=0;j<ns.length;j++){',
2457
+ 'if(hasImg(ns[j])){hit=ns[j];break;}',
2458
+ '}',
2459
+ 'if(hit)break;',
2460
+ '}',
2461
+ 'if(hit)fix(hit);',
2462
+ '}).observe(document.body,{childList:true,subtree:true});',
2463
+ '}catch(e){}',
2464
+ '})();'
2465
+ ].join('\n');
2466
+ document.head.appendChild(js);
2467
+ } catch (e) {}
2468
+ }
2469
+
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)); }
2535
+ }
2536
+
2537
+ exports.apply = apply;
2538
+ exports.inject = inject;
2539
+ return module.exports;
2540
+ }
2541
+ });