dsh-composer-live 0.2.11

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/client.js ADDED
@@ -0,0 +1,1852 @@
1
+ // dsh-composer-live — 浏览器端(client plugin bundle)。
2
+ //
3
+ // 手写 CJS + ModuleLoader 包装(零构建步骤,与 dsh-anthropic-style 相同形状)。
4
+ //
5
+ // 做什么(dsh 0.1.5-rc.2+ 的 Lexical contenteditable 输入框,仿 Open WebUI):
6
+ // 1. markdown 实时渲染:加粗/斜体/行内代码/删除线/链接/图片胶囊 + 代码块
7
+ // 背景/围栏隐藏/语法高亮/语言标签 + 标题/引用/列表/表格块级视觉
8
+ // 2. 代码块体验:``` 行 Enter/Shift+Enter 自动补全「块内空行+闭合围栏+块后
9
+ // 空行」的封闭块(光标落块内)、块内 Tab 两空格缩进、块内最后行 ↓ 直接
10
+ // 跳出到块后普通行、整框等宽(含块期间)
11
+ // 3. 格式工具栏:常驻顶条(B/I/code/link/list/block + 展开)+ 选区浮动条
12
+ // 4. 70vh 展开写作区
13
+ // 5. 大段粘贴自动包代码块(>8 行且像代码;中文长文不误伤)
14
+ // 6. Esc 分发:候选菜单开→归官方;展开态→收起;生成中→停止生成
15
+ // 7. 明暗主题自适应、行级增量渲染 + rAF 合帧(observer 回调同样合帧)、
16
+ // 中文字体恒定
17
+ //
18
+ // 架构(0.1.5 输入框 = shell 持有的 Lexical 编辑器;多行草稿=单 <p>+<br> 分行,
19
+ // Enter=发送 / Shift+Enter=换行;@ 引用 chip=span[data-composer-chip]):
20
+ // - 透明化:[data-composer-input] 用 -webkit-text-fill-color:transparent 只隐藏
21
+ // 「绘制」(color 属性保留原值:caret、官方 hint 的 ::after、chip 恢复都靠它)
22
+ // - chip 恢复:span[data-composer-chip] 恢复 currentColor;含 chip 的段落整体
23
+ // 恢复显示(KEEP)——overlay 无法复刻 chip 宽度,含 chip 段降级官方原样显示
24
+ // - overlay:自建 .dsh-cl-live 渲染层按行一一对应渲染;度量从行容器 <p> 复制
25
+ // + 首行动态 top 偏移(签名守卫,仅布局变化时测量)
26
+ // - 数据源:直接读编辑器 DOM 产出「投影文本」(br/p=换行、chip=)——零服务
27
+ // 依赖,不碰 conversation/sessions API;光标/选区 offset 互查基于锚表
28
+ // - 插入:单行走 execCommand insertText、多行走合成 beforeinput 事件序列
29
+ // (都要 setTimeout 延后;详见 insertInline 注释的四个实测坑)
30
+ // - 等宽整框:文档含已闭合代码块期间,编辑器整框 + 渲染全段同步切等宽合成栈
31
+ // (v0.1.x 同策略;行级等宽对大段粘贴的「单文本锚跨多行」形态物理不可行)
32
+ // - 候选菜单让行:[data-trigger-menu]/[role="listbox"] 存在或 data-phase≠plain
33
+ // 时,Enter/Tab/Esc/粘贴一律放行归官方
34
+ window.__ModuleLoader__.load({
35
+ id: "dsh-composer-live",
36
+ factory: (require) => {
37
+ 'use strict';
38
+ var module = { exports: {} };
39
+ var exports = module.exports;
40
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
41
+
42
+ //#region dsh-composer-live: 常量
43
+ /** 注入的 <style> 元素 id。 */
44
+ const STYLE_ID = "dsh-composer-live-style";
45
+ /** 输入卡片标记:已初始化则不重复注入监听器。 */
46
+ const READY_KEY = "data-dsh-cl-ready";
47
+ /** 展开态 class(挂在 [data-input-scroll])。 */
48
+ const EXPANDED = "dsh-cl-expanded";
49
+ /** 透明化宿主 class(挂在 [data-composer-input])。 */
50
+ const HOST = "dsh-cl-host";
51
+ /** 含 chip 的编辑器段落 class(恢复官方显示)。 */
52
+ const KEEP = "dsh-cl-keep";
53
+ /** 等宽整框 class(挂在 [data-composer-input]):文档含已闭合代码块期间整框切
54
+ * 等宽合成栈(v0.1.x 同策略;行级等宽对「字面 \n」单锚形态物理不可行)。 */
55
+ const MONO_ALL = "dsh-cl-mono-all";
56
+ /** 自建实时渲染层 class。 */
57
+ const LIVE = "dsh-cl-live";
58
+ /** 哨兵 class(读继承字体链)。 */
59
+ const SENTINEL = "dsh-cl-sentinel";
60
+ /** 常驻工具栏 class。 */
61
+ const TOOLBAR = "dsh-cl-toolbar";
62
+ /** 选区浮动格式条 class。 */
63
+ const FLOAT = "dsh-cl-float";
64
+ /** 轻提示 class。 */
65
+ const TOAST = "dsh-cl-toast";
66
+ /** DOM 锚点(0.1.5 官方稳定 data 属性;类名是随机 hash 不可用)。 */
67
+ const CARD_SEL = "[data-composer-card]";
68
+ const INPUT_SEL = "[data-composer-input]";
69
+ const SCROLL_SEL = "[data-input-scroll]";
70
+ const PLACEHOLDER_SEL = "[data-composer-placeholder]";
71
+ const MENU_SEL = '[data-trigger-menu], [role="listbox"], [role="menu"], [role="dialog"]';
72
+ const CHIP_HOST_SEL = "[data-composer-chip]";
73
+ /** 文本引用装饰(热词高亮):内容参与投影(非原子 chip),但透明化下需要恢复显示。 */
74
+ const TEXT_REF_SEL = "[data-composer-text-ref]";
75
+ /** chip 在「投影文本」里的字符(与官方 detectText 同构)。 */
76
+ const CHIP_CHAR = "";
77
+ /** 行内代码/图片占位控制字符(不与正文数字混淆)。 */
78
+ const PH_CODE = "";
79
+ const PH_IMG = "";
80
+ /** 防 XSS:链接 href 白名单协议。 */
81
+ const SAFE_LINK = /^(https?:|mailto:)/i;
82
+ /** 大段粘贴自动包代码块的阈值。 */
83
+ const PASTE_WRAP_MAX_LINES = 8;
84
+ const PASTE_WRAP_MAX_CHARS = 4096;
85
+ /** 「停止生成」按钮 aria-label(i18n 双语;实测校正清单见 README)。 */
86
+ const STOP_BTN_HINTS = ["停止生成", "Stop generating", "停止"];
87
+ /** 工具栏高度 + 呼吸(占位同步值)。 */
88
+ const BAR_RESERVE = "6px"; // v0.2.8:sticky 工具栏在流内占 38px,编辑器 padding 只留呼吸(原 absolute 方案的 44px 占位退役)
89
+ //#endregion
90
+
91
+ //#region dsh-composer-live: 安全工具
92
+ function escapeHtml(s) {
93
+ return String(s)
94
+ .replace(/&/g, "&amp;")
95
+ .replace(/</g, "&lt;")
96
+ .replace(/>/g, "&gt;")
97
+ .replace(/"/g, "&quot;")
98
+ .replace(/'/g, "&#39;")
99
+ // \r 必须走字符实体:innerHTML 解析会把字面 \r(含 \r\n)规范化成 \n
100
+ // ——段 text 里的 \r 会凭空变成真换行(pre-wrap 下断行)=粘贴长文
101
+ // 「多余空白/行错乱」的来源;实体引用不参与换行规范化,解析回 DOM
102
+ // 仍是 \r(0 宽,与编辑器渲染一致)。
103
+ .replace(/\r/g, "&#13;");
104
+ }
105
+
106
+ function safeHref(url) {
107
+ const u = String(url || "").trim();
108
+ return SAFE_LINK.test(u) ? u : "";
109
+ }
110
+ //#endregion
111
+
112
+ //#region dsh-composer-live: 行内 Markdown 解析(纯函数,与旧版同源)
113
+ /** 围栏行判定:行首(允许缩进)三个及以上 ` 或 ~,后面可跟语言名。 */
114
+ const FENCE_RE = /^(\s*)(`{3,}|~{3,})([\w+#./-]*)\s*$/;
115
+
116
+ /**
117
+ * 行内 Markdown → HTML。支持加粗/斜体/行内代码/删除线/链接;图片与行内代码
118
+ * 用控制字符占位后整体 escapeHtml,防内部二次解析。与旧版 dsh-composer-md
119
+ * 的 inlineParse 同源(class 前缀已换 dsh-cl-)。
120
+ *
121
+ * 字符级对齐(2026-09-13):标记字符(**、`、~~、[]())必须保留占位(mk 淡化
122
+ * span),不能删除——光标按编辑器透明文字布局绘制,渲染行少字符=光标漂移;
123
+ * 粗体不用 font-weight(改字符 advance),用 -webkit-text-stroke 伪粗(paint-only)。
124
+ */
125
+ var MK_OPEN = '<span class="dsh-cl-mk">';
126
+ function inlineParse(line, noMono) {
127
+ const codeSpans = [];
128
+ const imgSpans = [];
129
+ let out = String(line || "");
130
+
131
+ out = out.replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g, (m) => {
132
+ imgSpans.push(escapeHtml(m));
133
+ return PH_IMG + (imgSpans.length - 1) + PH_IMG;
134
+ });
135
+
136
+ out = out.replace(/(^|[^`])`([^`]+)`(?!`)/g, (m, pre, code) => {
137
+ codeSpans.push(escapeHtml(code));
138
+ return pre + PH_CODE + (codeSpans.length - 1) + PH_CODE;
139
+ });
140
+
141
+ out = escapeHtml(out);
142
+
143
+ out = out.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (m, text, url) => {
144
+ const href = safeHref(url);
145
+ if (!href) return m;
146
+ return MK_OPEN + "[</span>" + '<a href="' + href + '" target="_blank" rel="noopener noreferrer">' + text + "</a>" + MK_OPEN + "](" + url + ")</span>";
147
+ });
148
+
149
+ out = out.replace(/\*\*\*([^*]+)\*\*\*/g, MK_OPEN + "***</span><strong><em>$1</em></strong>" + MK_OPEN + "***</span>");
150
+ // 内容组排除 <(escape 后正文无裸 <,只有本函数产出的标签)——防标记
151
+ // 替换产物被后续规则二次匹配(*** 产物里的 ** 再被 bold 规则嵌套)。
152
+ out = out.replace(/\*\*([^*<]+)\*\*/g, MK_OPEN + "**</span><strong>$1</strong>" + MK_OPEN + "**</span>");
153
+ out = out.replace(/(^|[^*<])\*([^*\s<][^*<]*)\*(?!\*)/g, "$1" + MK_OPEN + "*</span><em>$2</em>" + MK_OPEN + "*</span>");
154
+ out = out.replace(/~~([^~<]+)~~/g, MK_OPEN + "~~</span><del>$1</del>" + MK_OPEN + "~~</span>");
155
+
156
+ out = out.replace(new RegExp(PH_CODE + "(\\d+)" + PH_CODE, "g"), (m, i) => MK_OPEN + "`</span><code" + (noMono ? ' class="dsh-cl-ce"' : "") + ">" + codeSpans[+i] + "</code>" + MK_OPEN + "`</span>");
157
+ out = out.replace(new RegExp(PH_IMG + "(\\d+)" + PH_IMG, "g"), (m, i) => '<span class="dsh-cl-img">' + imgSpans[+i] + "</span>");
158
+ return out;
159
+ }
160
+ //#endregion
161
+
162
+ //#region dsh-composer-live: 代码语法高亮(零依赖正则,颜色-only)
163
+ const LANG_ALIAS = {
164
+ js: "js", jsx: "js", mjs: "js", cjs: "js", ts: "js", tsx: "js",
165
+ javascript: "js", typescript: "js", node: "js",
166
+ py: "py", python: "py", python3: "py",
167
+ sh: "sh", bash: "sh", shell: "sh", zsh: "sh", console: "sh", fish: "sh",
168
+ json: "json", yml: "yml", yaml: "yml", toml: "yml",
169
+ css: "css", scss: "css", less: "css",
170
+ html: "html", htm: "html", xml: "html", svg: "html", vue: "html"
171
+ };
172
+
173
+ const KEYWORDS = {
174
+ js: new Set("const let var function return if else for while do switch case break continue new class extends super import export from as default try catch finally throw typeof instanceof in of async await yield this delete void static get set console require module".split(" ")),
175
+ py: new Set("def return if elif else for while break continue pass class import from as with lambda yield global nonlocal raise try except finally assert del in is not and or self print len range int str float list dict set tuple bool type enumerate zip map filter sorted sum min max abs open super".split(" ")),
176
+ sh: new Set("if then else elif fi for while do done case esac in function echo export cd return local source set sudo apt pnpm npm git curl grep sed awk cat mkdir rm cp mv ls chmod".split(" "))
177
+ };
178
+
179
+ const LITERALS = new Set("true false null undefined NaN None True False".split(" "));
180
+
181
+ const HL_RE = {
182
+ js: /("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`)|(\/\/[^\n]*|\/\*[\s\S]*?\*\/)|(\b\d[\d_]*(?:\.\d+)?\b)|([A-Za-z_$][\w$]*)/g,
183
+ py: /("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')|(#[^\n]*)|(\b\d[\d_]*(?:\.\d+)?\b)|([A-Za-z_][\w]*)/g,
184
+ sh: /("(?:[^"\\]|\\.)*"|'[^']*')|(#[^\n]*)|(\b\d[\d_]*(?:\.\d+)?\b)|([A-Za-z_][\w-]*)/g,
185
+ json: /("(?:[^"\\]|\\.)*")|(\/\/[^\n]*)|(-?\b\d[\d_]*(?:\.\d+)?\b)|([A-Za-z_][\w]*)/g,
186
+ gen: /("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*`)|(\/\/[^\n]*|#[^\n]*|<!--[\s\S]*?-->|--[^\n]*)|(\b\d[\d_]*(?:\.\d+)?\b)|([A-Za-z_][\w-]*)/g
187
+ };
188
+
189
+ function guessLang(text) {
190
+ const t = String(text || "");
191
+ if (/^\s*[{[]/.test(t) && /"[^"\n]*"\s*:/.test(t)) return "json";
192
+ if (/\b(def |elif |import \w+|print\()/.test(t)) return "py";
193
+ if (/\b(const |let |var |function|=>|console\.|require\(|export |await )/.test(t)) return "js";
194
+ if (/^\s*(\$\(|npm |pnpm |yarn |sudo |apt |brew |git |cd |ls |echo |mkdir )/m.test(t)) return "sh";
195
+ if (/[{}]\s*;|;\s*$/m.test(t) && /=/.test(t)) return "js";
196
+ return "";
197
+ }
198
+
199
+ function highlightLine(line, lang) {
200
+ const family = LANG_ALIAS[lang] || "gen";
201
+ const re = HL_RE[family] || HL_RE.gen;
202
+ const kw = KEYWORDS[family];
203
+ re.lastIndex = 0;
204
+ let out = "";
205
+ let last = 0;
206
+ let m;
207
+ while ((m = re.exec(line))) {
208
+ out += escapeHtml(line.slice(last, m.index));
209
+ const full = m[0];
210
+ const after = line.slice(m.index + full.length);
211
+ const before = line.slice(0, m.index);
212
+ let cls = "";
213
+ if (m[1]) {
214
+ cls = family === "json" && /^\s*:/.test(after) ? "dsh-cl-tok-fn" : "dsh-cl-tok-str";
215
+ } else if (m[2]) {
216
+ cls = "dsh-cl-tok-com";
217
+ } else if (m[3]) {
218
+ cls = "dsh-cl-tok-num";
219
+ } else if (m[4]) {
220
+ if (kw && kw.has(m[4])) cls = "dsh-cl-tok-kw";
221
+ else if (LITERALS.has(m[4])) cls = "dsh-cl-tok-lit";
222
+ else if (family === "html" && /<\/?$/.test(before)) cls = "dsh-cl-tok-kw";
223
+ else if (/^\s*\(/.test(after)) cls = "dsh-cl-tok-fn";
224
+ }
225
+ out += cls ? '<span class="' + cls + '">' + escapeHtml(full) + "</span>" : escapeHtml(full);
226
+ last = m.index + full.length;
227
+ }
228
+ out += escapeHtml(line.slice(last));
229
+ return out || " ";
230
+ }
231
+ //#endregion
232
+
233
+ //#region dsh-composer-live: 块状态与段落渲染(行=段落,与旧版同源)
234
+ const NBSP = " ";
235
+
236
+ function codeLineContent(line, extraCls, lang, withBadge, isCursor) {
237
+ const isFence = extraCls === "dsh-cl-fence-open" || extraCls === "dsh-cl-fence-close";
238
+ let content;
239
+ if (!line) content = NBSP;
240
+ // 围栏字符统一淡化(mk):可见弱化、光标行无需特判(字符锚定渲染下
241
+ // 没有「行级隐藏+光标行显示」的切换,统一淡化消灭切换闪烁)
242
+ else if (isFence) content = '<span class="dsh-cl-mk">' + escapeHtml(line) + "</span>";
243
+ else content = highlightLine(line, lang);
244
+ const badge = withBadge && lang ? '<span class="dsh-cl-lang">' + escapeHtml(lang) + "</span>" : "";
245
+ return content + badge;
246
+ }
247
+
248
+ function blockKindOf(line) {
249
+ const l = String(line || "");
250
+ let m;
251
+ m = l.match(/^(#{1,6})(\s+)(.+)$/);
252
+ if (m) return { type: "heading", mark: m[1], space: m[2], text: m[3] };
253
+ m = l.match(/^(>)(\s?)(.*)$/);
254
+ if (m) return { type: "quote", mark: m[1], space: m[2], text: m[3] };
255
+ m = l.match(/^(\s{0,3})([-*+])(\s+)(.+)$/);
256
+ if (m) return { type: "list", indent: m[1], mark: m[2], space: m[3], text: m[4] };
257
+ m = l.match(/^(\s{0,3})(\d+)([.)])(\s+)(.+)$/);
258
+ if (m) return { type: "list", indent: m[1], mark: m[2] + m[3], space: m[4], text: m[5] };
259
+ if (/^\s*\|?[\s:|-]+\|[\s:|-]*$/.test(l) && /-{3,}/.test(l)) return { type: "table", raw: l };
260
+ return null;
261
+ }
262
+
263
+ function renderBlockLine(line, kind, noMono) {
264
+ if (!kind) return inlineParse(line, noMono);
265
+ if (kind.type === "heading") return '<span class="dsh-cl-hmark">' + escapeHtml(kind.mark) + '</span>' + escapeHtml(kind.space) + '<span class="dsh-cl-heading">' + inlineParse(kind.text, noMono) + '</span>';
266
+ if (kind.type === "quote") return '<span class="dsh-cl-qmark">' + escapeHtml(kind.mark) + '</span>' + escapeHtml(kind.space) + inlineParse(kind.text, noMono);
267
+ if (kind.type === "list") {
268
+ // 任务列表勾选样式(v0.2.10):`- [ ] 任务` / `- [x] 任务`——标记三字符
269
+ // 整体进胶囊背景(字符流不变,光标对齐不受影响);已勾时行内容加划线
270
+ // (<s> → tokenizeViaDom 映射 dsh-cl-dl,line-through 是 paint-only)。
271
+ const tm = kind.text.match(/^(\[(?: |x|X)\])(\s+)([\s\S]*)$/);
272
+ if (tm) {
273
+ const done = tm[1].charAt(1) !== " ";
274
+ return escapeHtml(kind.indent || "") + '<span class="dsh-cl-lmark">' + escapeHtml(kind.mark) + '</span>' + escapeHtml(kind.space)
275
+ + '<span class="dsh-cl-task' + (done ? " dsh-cl-task-done" : "") + '">' + escapeHtml(tm[1]) + '</span>' + escapeHtml(tm[2])
276
+ + (done ? "<s>" + inlineParse(tm[3], noMono) + "</s>" : inlineParse(tm[3], noMono));
277
+ }
278
+ return escapeHtml(kind.indent || "") + '<span class="dsh-cl-lmark">' + escapeHtml(kind.mark) + '</span>' + escapeHtml(kind.space) + inlineParse(kind.text, noMono);
279
+ }
280
+ if (kind.type === "table") return '<span class="dsh-cl-tmark">' + escapeHtml(kind.raw) + "</span>";
281
+ return inlineParse(line, noMono);
282
+ }
283
+
284
+ function inlineLineContent(line, noMono) {
285
+ if (!line) return NBSP;
286
+ return renderBlockLine(line, blockKindOf(line), noMono);
287
+ }
288
+
289
+ function computeBlockStates(lines) {
290
+ const states = [];
291
+ const blocks = [];
292
+ let inCode = false;
293
+ let cur = null;
294
+ for (let i = 0; i < lines.length; i++) {
295
+ const fm = lines[i].match(FENCE_RE);
296
+ if (fm) {
297
+ if (!inCode) {
298
+ cur = { start: i, lang: (fm[3] || "").toLowerCase() };
299
+ blocks.push(cur);
300
+ } else {
301
+ cur.end = i;
302
+ }
303
+ inCode = !inCode;
304
+ }
305
+ }
306
+ const blockAt = new Map();
307
+ for (const b of blocks) {
308
+ // 只渲染「已闭合」的块:未闭合(end==null)= 用户还在围栏输入阶段
309
+ // (打了 ``` 还没按 Shift+Enter 补全,或删掉闭合围栏编辑中),整块按
310
+ // 普通文本渲染——代码块样式在补全封闭的那一刻才出现(2026-09-12
311
+ // 用户指定的流:输入 ``` 界面不出块,Shift+Enter 后出封闭块)。
312
+ if (b.end == null) continue;
313
+ if (!b.lang) b.lang = guessLang(lines.slice(b.start + 1, b.end).join("\n"));
314
+ for (let i = b.start; i <= b.end; i++) blockAt.set(i, b);
315
+ }
316
+ for (let i = 0; i < lines.length; i++) {
317
+ const b = blockAt.get(i);
318
+ if (!b) { states.push({ inCode: false, lang: "", fenceOpen: false, fenceClose: false, kind: blockKindOf(lines[i]) }); continue; }
319
+ states.push({ inCode: true, lang: b.lang, fenceOpen: i === b.start, fenceClose: i === b.end, kind: null });
320
+ }
321
+ return states;
322
+ }
323
+
324
+ function lineClass(st) {
325
+ if (!st.inCode) return st.kind && st.kind.type === "quote" ? "dsh-cl-quote" : "";
326
+ return "dsh-cl-cline" + (st.fenceOpen ? " dsh-cl-fence-open" : "") + (st.fenceClose ? " dsh-cl-fence-close" : "");
327
+ }
328
+
329
+ function lineContent(line, st, cursorLine) {
330
+ if (!st.inCode) return inlineLineContent(line, cursorLine);
331
+ return codeLineContent(line, st.fenceOpen ? "dsh-cl-fence-open" : (st.fenceClose ? "dsh-cl-fence-close" : ""), st.lang, st.fenceOpen, cursorLine);
332
+ }
333
+
334
+ /** 段落是否含 chip 投影字符()。 */
335
+ function paraHasChip(line) {
336
+ return String(line || "").indexOf(CHIP_CHAR) !== -1;
337
+ }
338
+
339
+ /**
340
+ * 渲染主体(逐段落,与编辑器段落一一对应)。含 chip 的段落渲染为空占位
341
+ * (该段落由官方原样显示,KEEP class 恢复——overlay 无法复刻 chip 宽度,
342
+ * 零错位优先)。
343
+ */
344
+ function renderInline(src, cursorLine) {
345
+ const lines = String(src || "").split("\n");
346
+ const states = computeBlockStates(lines);
347
+ let out = "";
348
+ for (let i = 0; i < lines.length; i++) {
349
+ if (paraHasChip(lines[i])) {
350
+ out += '<div class="dsh-cl-skip">' + NBSP + "</div>";
351
+ continue;
352
+ }
353
+ const cls = lineClass(states[i]);
354
+ out += '<div' + (cls ? ' class="' + cls + '"' : "") + '>' + lineContent(lines[i], states[i], i === cursorLine) + "</div>";
355
+ }
356
+ return out;
357
+ }
358
+
359
+ /** 段落级代码态列表(供编辑器段落打等宽 class;行=段落)。 */
360
+ function codeParaFlags(lines) {
361
+ return computeBlockStates(lines).map((st) => st.inCode);
362
+ }
363
+
364
+ // ---------- 字符锚定渲染(v0.2 根治架构) ----------
365
+ // 渲染层不再自己排版:逐字符测量编辑器的真实坐标(Range rect),渲染
366
+ // 字符绝对定位画在同一坐标上。字体/字重/断行/行结构/等宽打点时序/任何
367
+ // 度量维度怎么变,渲染物理跟随编辑器——漂移/抖动/错位从架构上不存在。
368
+ // 这是对「透明编辑器 + overlay 两遍绘制」开环逼近的历史总结:修不胜修
369
+ // 的根因是两套排版引擎各自计算,字符锚定后只剩一套(编辑器)。
370
+
371
+ /** 行 HTML → token 序列(复用行内/高亮正则链,DOM 解析嵌套取样式类)。 */
372
+ function tokenizeViaDom(html) {
373
+ const holder = document.createElement("div");
374
+ holder.innerHTML = html;
375
+ const tokens = [];
376
+ const clsOf = (el, inherited) => {
377
+ const tag = el.tagName;
378
+ if (tag === "STRONG" || tag === "B") return "dsh-cl-strong";
379
+ if (tag === "EM" || tag === "I") return "dsh-cl-em";
380
+ if (tag === "CODE") return "dsh-cl-cd";
381
+ if (tag === "DEL" || tag === "S") return "dsh-cl-dl";
382
+ if (tag === "A") return "dsh-cl-lk";
383
+ const cn = String(el.className || "");
384
+ if (cn) return cn; // mk / hmark / heading / qmark / lmark / tmark / img / tok-*
385
+ return inherited;
386
+ };
387
+ const walk = (el, cls) => {
388
+ for (const n of Array.prototype.slice.call(el.childNodes)) {
389
+ if (n.nodeType === Node.TEXT_NODE) {
390
+ if (n.nodeValue) tokens.push({ text: n.nodeValue, cls: cls || "" });
391
+ } else if (n.nodeType === Node.ELEMENT_NODE) {
392
+ walk(n, clsOf(n, cls));
393
+ }
394
+ }
395
+ };
396
+ walk(holder, "");
397
+ return tokens;
398
+ }
399
+
400
+ /**
401
+ * 逐行逐字符测量编辑器 rect。返回 lineChars[行] = [{off(行内偏移), x, y, w, h}];
402
+ * chip 跳过(其所在行整段降级官方显示)。
403
+ */
404
+ function measureAnchorChars(anchors, lineCount) {
405
+ const range = document.createRange();
406
+ const lineChars = [];
407
+ for (let i = 0; i < lineCount; i++) lineChars.push([]);
408
+ let lineIdx = 0;
409
+ let off = 0; // 行内字符偏移
410
+ for (const a of anchors) {
411
+ if (a.kind === "br" || a.kind === "para") { lineIdx++; off = 0; continue; }
412
+ if (a.kind === "chip") { off++; continue; }
413
+ const node = a.node;
414
+ if (!node || node.nodeType !== Node.TEXT_NODE) { continue; }
415
+ const len = node.nodeValue.length;
416
+ // 逐锚容错:大段粘贴/批量编辑期间 DOM 可能正被 Lexical 重建,个别
417
+ // 节点测量抛错时跳过该锚(下一轮渲染自愈),不让整个渲染中断。
418
+ // 文本节点内的字面 \n 必须推进行号(与 src.split("\n") 的行边界
419
+ // 一致)——粘贴的大段文本正是「单节点含字面 \n」形态(官方 paste
420
+ // 管线 insertText 不转 br),不推进会导致 chars 全记到第 0 行而
421
+ // lines 已被切多行 → charAt 越界 → 渲染段全空 →「一片空白」。
422
+ try {
423
+ for (let i = 0; i < len; i++) {
424
+ if (node.nodeValue.charCodeAt(i) === 10) { lineIdx++; off = 0 - i - 1; continue; }
425
+ range.setStart(node, i);
426
+ range.setEnd(node, i + 1);
427
+ const r = range.getBoundingClientRect();
428
+ if (r) lineChars[lineIdx].push({ off: off + i, x: r.left, y: r.top, w: r.width, h: r.height });
429
+ }
430
+ } catch (err) { /* 跳过该锚 */ }
431
+ off += len;
432
+ }
433
+ return lineChars;
434
+ }
435
+
436
+ /** 字符锚定渲染主入口:清空 overlay,按测量坐标输出「整块背景 + 定位字符段」。 */
437
+ function renderCharOverlay(ov, input, anchors, lines, states) {
438
+ const ovRect = ov.getBoundingClientRect();
439
+ const ox = ovRect.left, oy = ovRect.top;
440
+ const segLineHeight = getComputedStyle(ov).lineHeight; // 段定位的 half-leading 修正用
441
+ const lineChars = measureAnchorChars(anchors, lines.length);
442
+ // 容器内容区(块背景的水平范围——统一宽度消灭按行宽的锯齿)
443
+ const inpRect = input.getBoundingClientRect();
444
+ const ics = getComputedStyle(input);
445
+ const pl = parseFloat(ics.paddingLeft) || 0, pr = parseFloat(ics.paddingRight) || 0;
446
+ const contentLeft = inpRect.left + pl - 12;
447
+ const contentW = inpRect.width - pl - pr + 24;
448
+ let out = "";
449
+ // ===== 块级背景(代码块整块一个矩形:垂直无缝;引用段同理) =====
450
+ // 逐行画背景会按行内容宽度锯齿 + 行间露缝——聚合到块级:从块首行首字符顶
451
+ // 到块末行末字符底 ±2.5px,水平取容器内容宽。块内空行天然被矩形覆盖。
452
+ const blockBgDone = new Set();
453
+ const verticalPad = 2.5;
454
+ for (let li = 0; li < lines.length; li++) {
455
+ const st = states[li];
456
+ if (!st.inCode || blockBgDone.has(li)) continue;
457
+ // 找块尾(连续 inCode 段的最后一行)
458
+ let end = li;
459
+ while (end + 1 < lines.length && states[end + 1].inCode) end++;
460
+ let top = Infinity, bottom = -Infinity;
461
+ for (let k = li; k <= end; k++) {
462
+ for (const c of lineChars[k]) { if (c.y < top) top = c.y; if (c.y + c.h > bottom) bottom = c.y + c.h; }
463
+ }
464
+ if (top === Infinity) { // 整块无字符(空块)——跳过
465
+ for (let k = li; k <= end; k++) blockBgDone.add(k);
466
+ continue;
467
+ }
468
+ const cls = "dsh-cl-bg" + (st.fenceOpen ? " dsh-cl-bg-open" : "") + (states[end].fenceClose ? " dsh-cl-bg-close" : "");
469
+ out += '<div class="' + cls + '" style="left:' + (contentLeft - ox) + "px;top:" + (top - oy - verticalPad) + "px;width:" + contentW + "px;height:" + (bottom - top + verticalPad * 2) + 'px"></div>';
470
+ if (st.fenceOpen && st.lang) out += '<span class="dsh-cl-lang" style="top:' + (top - oy + 2) + 'px">' + escapeHtml(st.lang) + "</span>";
471
+ for (let k = li; k <= end; k++) blockBgDone.add(k);
472
+ }
473
+ // 引用段(连续 quote 行聚合一个背景)
474
+ const quoteBgDone = new Set();
475
+ for (let li = 0; li < lines.length; li++) {
476
+ const st = states[li];
477
+ if (!st.kind || st.kind.type !== "quote" || quoteBgDone.has(li)) continue;
478
+ let end = li;
479
+ while (end + 1 < lines.length && states[end + 1].kind && states[end + 1].kind.type === "quote" && !paraHasChip(lines[end + 1])) end++;
480
+ let top = Infinity, bottom = -Infinity;
481
+ for (let k = li; k <= end; k++) {
482
+ for (const c of lineChars[k]) { if (c.y < top) top = c.y; if (c.y + c.h > bottom) bottom = c.y + c.h; }
483
+ }
484
+ if (top !== Infinity) out += '<div class="dsh-cl-qbg" style="left:' + (contentLeft - ox) + "px;top:" + (top - oy - verticalPad) + "px;width:" + contentW + "px;height:" + (bottom - top + verticalPad * 2) + 'px"></div>';
485
+ for (let k = li; k <= end; k++) quoteBgDone.add(k);
486
+ }
487
+ // ===== 字符段渲染 =====
488
+ for (let li = 0; li < lines.length; li++) {
489
+ const line = lines[li];
490
+ const st = states[li];
491
+ if (paraHasChip(line)) continue; // chip 行:官方原样(KEEP)
492
+ const chars = lineChars[li];
493
+ if (!chars.length) continue; // 空行:背景已由块级矩形覆盖
494
+ // 行字符的样式 token(HTML 复用既有正则链 → DOM 解析)
495
+ let html;
496
+ if (st.inCode) html = codeLineContent(line, st.fenceOpen ? "dsh-cl-fence-open" : (st.fenceClose ? "dsh-cl-fence-close" : ""), st.lang, st.fenceOpen, -2);
497
+ else html = inlineLineContent(line, null);
498
+ const tokens = tokenizeViaDom(html);
499
+ // token → 每字符样式类(token 文本流 = 源文字符流,mk 占位保证逐字符对应)
500
+ const charCls = new Array(chars.length).fill("");
501
+ let ci = 0;
502
+ for (const tk of tokens) {
503
+ const tl = tk.text.length;
504
+ for (let k = 0; k < tl && ci < chars.length; k++, ci++) charCls[ci] = tk.cls;
505
+ }
506
+ // 合并「同样式 + 同视觉行 + 横向连续」的字符成段(段内浏览器排版同字体零差)。
507
+ // 等宽整框语义:文档含代码块时全部段用等宽合成栈(与编辑器 MONO_ALL 同源变量)。
508
+ const anyCode = states.some((st) => st.inCode);
509
+ const codeCls = anyCode ? " dsh-cl-codefont" : "";
510
+ // 段定位必须用「行盒顶」而非「字形盒顶」:段内字符随段自身行盒排版,
511
+ // 字形顶 = 段top + half-leading((行高-字形高)/2)。直接拿测量出的字形顶
512
+ // 定位会让渲染文字整体下移 half-leading(实测恒 +3px:行高27/字形21)。
513
+ // 段内其余字符的相对排布与编辑器同构(同字体同行高同基线规则),首字符
514
+ // 对齐即全段对齐;代码行等宽段用该段首字符的字形高计算。
515
+ const segLineH = parseFloat(segLineHeight) || 0;
516
+ let segStart = 0;
517
+ const flush = (endExclusive) => {
518
+ const s = chars[segStart];
519
+ let text = "";
520
+ // 段 text 剔除 \r:CRLF 粘贴的行尾 \r 在段内(white-space:pre)会被当
521
+ // 真换行——段盒凭空多撑一行(代码块背景多一块)。编辑器把 \r 当
522
+ // 换行语义的一部分(0 宽不可见),渲染侧同样零宽处理=直接不画。
523
+ for (let k = segStart; k < endExclusive; k++) {
524
+ const ch = line.charAt(chars[k].off);
525
+ if (ch !== "\r") text += ch;
526
+ }
527
+ if (!text) { segStart = endExclusive; return; } // 整段全 \r:无可视字符,不输出空段
528
+ const cls = charCls[segStart] + codeCls;
529
+ const half = segLineH > s.h ? (segLineH - s.h) / 2 : 0;
530
+ out += '<span class="dsh-cl-c' + (cls ? " " + cls : "") + '" style="left:' + (s.x - ox) + "px;top:" + (s.y - oy - half) + 'px">' + escapeHtml(text) + "</span>";
531
+ segStart = endExclusive;
532
+ };
533
+ for (let i = 1; i <= chars.length; i++) {
534
+ const cont = i < chars.length
535
+ && charCls[i] === charCls[segStart]
536
+ && Math.abs(chars[i].y - chars[segStart].y) < 2
537
+ && (chars[i].x - (chars[i - 1].x + chars[i - 1].w)) < 1.5;
538
+ if (!cont) flush(i);
539
+ }
540
+ }
541
+ ov.innerHTML = out;
542
+ }
543
+ //#endregion
544
+
545
+ //#region dsh-composer-live: 字体合成 / 判定函数(与旧版同源)
546
+ const CJK_FONT_HINTS = ["cjk", "yahei", "pingfang", "hiragino", "simsun", "simhei",
547
+ "kaiti", "fangsong", "heiti", "songti", "dengxian", "zenhei", "wqy", "wenkai",
548
+ "lxgw", "sarasa", "noto sans sc", "noto serif sc", "source han", "misans", "nf cn",
549
+ "harmonyos", "honor sans", "oppo sans", "smiley", "hyqihei", "hywenhei",
550
+ "jinghua", "fz", " han", "sans sc", "serif sc"];
551
+ const GENERIC_FAMILIES = new Set(["monospace", "serif", "sans-serif", "cursive",
552
+ "fantasy", "system-ui", "ui-monospace", "ui-serif", "ui-sans-serif",
553
+ "ui-rounded", "math", "emoji", "fangsong", "inherit", "initial"]);
554
+ function buildCodeFont(codeStack, uiStack) {
555
+ const parts = String(codeStack || "").split(",").map((s) => s.trim()).filter(Boolean);
556
+ const kept = parts.filter((p) => {
557
+ const bare = p.replace(/^["']+|["']+$/g, "").trim().toLowerCase();
558
+ if (GENERIC_FAMILIES.has(bare)) return false;
559
+ return !CJK_FONT_HINTS.some((h) => bare.includes(h));
560
+ });
561
+ const ui = String(uiStack || "").trim();
562
+ return (kept.length ? kept.join(", ") + ", " : "") + ui;
563
+ }
564
+
565
+ function decideWrapAction(value, selStart, selEnd, kind) {
566
+ const s = selStart == null ? 0 : selStart;
567
+ const e = selEnd == null ? s : selEnd;
568
+ const sel = String(value || "").slice(s, e);
569
+ const pairs = {
570
+ bold: ["**", "**"],
571
+ italic: ["*", "*"],
572
+ code: ["`", "`"],
573
+ link: ["[", "](url)"],
574
+ list: null,
575
+ block: null
576
+ };
577
+ if (kind === "list") {
578
+ const before = String(value || "").slice(0, s);
579
+ const lineStart = before.lastIndexOf("\n") + 1;
580
+ if (String(value || "").slice(lineStart, lineStart + 2) === "- ") return null;
581
+ return { insert: "- ", cursorStart: s + 2, cursorEnd: s + 2, at: lineStart };
582
+ }
583
+ if (kind === "block") {
584
+ const after = String(value || "").slice(e);
585
+ const insert = "```\n" + sel + "\n```" + (after && !after.startsWith("\n") ? "\n" : "");
586
+ return { insert: insert, cursorStart: s + 4, cursorEnd: s + 4 + sel.length };
587
+ }
588
+ const pair = pairs[kind];
589
+ if (!pair) return null;
590
+ const insert = pair[0] + sel + pair[1];
591
+ if (!sel) {
592
+ const c = s + pair[0].length;
593
+ return { insert: insert, cursorStart: c, cursorEnd: c };
594
+ }
595
+ return { insert: insert, cursorStart: s, cursorEnd: s + insert.length };
596
+ }
597
+
598
+ const LIST_RE = /^(\s*)([-*+]|(\d{1,9})([.)]))(\s+)/;
599
+ /** 引用行前缀(缩进 + > + 可选空白;`>text` 无空格也认——CommonMark 语义)。 */
600
+ const QUOTE_RE = /^(\s*)(>)(\s*)/;
601
+
602
+ function decideKeyAction(value, pos, key, mods) {
603
+ if (key === "Enter") {
604
+ // 围栏行上的 Enter(含 Shift+Enter):自动补全完整代码块——
605
+ // 块内空行(光标落点)+ 闭合围栏 + 块后空行(↓ 跳出落点)。
606
+ // Shift+Enter 原语义是官方换行,在围栏行上替换为补全(用户流:
607
+ // 输入 ``` 后 Shift+Enter → 出现封闭代码块);无 Shift 的 Enter
608
+ // 原语义是发送,拦截防误发并同样补全。
609
+ const before = String(value).slice(0, pos);
610
+ const lineStart = before.lastIndexOf("\n") + 1;
611
+ const m = before.slice(lineStart).match(/^(\s*)(`{3,}|~{3,})([\w+#./-]*)\s*$/);
612
+ if (!m) {
613
+ // 表格首行行尾的 Enter(含 Shift+Enter):自动补分隔行 + 空行,
614
+ // 光标落空行(继续打表体)。只认首尾包夹形态(|列A|列B|),且下一
615
+ // 行已是分隔行时不重复补(防连按 Enter 反复插入)。
616
+ const tv = String(value);
617
+ const tnl = tv.indexOf("\n", lineStart);
618
+ const tEnd = tnl === -1 ? tv.length : tnl;
619
+ const tbl = tv.slice(lineStart, tEnd).match(/^\s*\|(.+)\|\s*$/);
620
+ if (tbl && tbl[1].indexOf("|") !== -1 && pos === tEnd) {
621
+ const nextNl = tv.indexOf("\n", tEnd + 1); // 从行尾+1 搜(tEnd 处本身就是 \n)
622
+ const nextLine = tv.slice(tEnd + 1, nextNl === -1 ? tv.length : nextNl);
623
+ if (!/^\s*\|[-\s|:]*-{3,}/.test(nextLine)) {
624
+ const cols = tbl[1].split("|").length;
625
+ const sep = "\n|" + "---|".repeat(cols) + "\n";
626
+ return { type: "table-sep", insert: sep, cursor: pos + sep.length };
627
+ }
628
+ }
629
+ // 列表行尾的 Enter(含 Shift+Enter):自动续项(无序保持符号、
630
+ // 有序递增编号、缩进保留)。空列表项(只有标记没内容)上按 →
631
+ // 退出列表:删除行首标记,行原地变普通行(两次 Shift+Enter
632
+ // 即结束列表的流:第一次续出空项,第二次退出)。光标不在行尾
633
+ // 时放行官方(本行内普通换行,不做拆项)。
634
+ const text = String(value);
635
+ const nlIdx = text.indexOf("\n", lineStart);
636
+ const lineEnd = nlIdx === -1 ? text.length : nlIdx;
637
+ const curLine = text.slice(lineStart, lineEnd);
638
+ const lm = curLine.match(LIST_RE);
639
+ if (lm && pos === lineEnd) {
640
+ const rest = curLine.slice(lm[0].length);
641
+ if (rest.trim() === "") {
642
+ // 空项退出:删除「缩进+标记+空白」,光标落行首
643
+ return { type: "list-exit", rangeStart: lineStart, rangeEnd: lineStart + lm[0].length, cursor: lineStart };
644
+ }
645
+ const mark = lm[2];
646
+ const nextMark = lm[3] ? String(parseInt(lm[3], 10) + 1) + lm[4] : mark;
647
+ const prefix = lm[1] + nextMark + " ";
648
+ return { type: "list", insert: "\n" + prefix, cursor: pos + 1 + prefix.length };
649
+ }
650
+ // 引用行尾的 Enter(含 Shift+Enter):自动续「> 」前缀(与列表续项
651
+ // 同款交互——两次 Shift+Enter 结束引用:第一次续出空引用行、第二次
652
+ // 退出变普通行)。光标不在行尾放行官方。
653
+ const qm = curLine.match(QUOTE_RE);
654
+ if (qm && pos === lineEnd) {
655
+ if (curLine.slice(qm[0].length).trim() === "") {
656
+ // 空引用项退出:删除「缩进+>+空白」,光标落行首
657
+ return { type: "list-exit", rangeStart: lineStart, rangeEnd: lineStart + qm[0].length, cursor: lineStart };
658
+ }
659
+ const qprefix = qm[1] + "> ";
660
+ return { type: "list", insert: "\n" + qprefix, cursor: pos + 1 + qprefix.length };
661
+ }
662
+ return null;
663
+ }
664
+ let fences = 0;
665
+ for (const l of before.slice(0, lineStart).split("\n")) {
666
+ if (FENCE_RE.test(l)) fences++;
667
+ }
668
+ if (fences % 2 === 1) return null;
669
+ const fence = m[2][0] === "~" ? "~~~" : "```";
670
+ const insert = "\n\n" + fence + "\n";
671
+ return { type: "fence", insert: insert, cursor: pos + 1 };
672
+ }
673
+ if (key === "ArrowDown" && !mods.shift && !mods.ctrl && !mods.alt && !mods.meta) {
674
+ // 代码块内最后一个内容行按 ↓:直接跳出(光标落到闭合围栏后的行首),
675
+ // 免去逐行穿越闭合围栏。仅已闭合块;未闭合(EOF 收尾)放行官方。
676
+ const text = String(value);
677
+ const s = Math.max(0, Math.min(pos == null ? 0 : pos, text.length));
678
+ const lines = text.split("\n");
679
+ const cur = text.slice(0, s).split("\n").length - 1;
680
+ if (cur >= lines.length - 1) return null;
681
+ if (FENCE_RE.test(lines[cur])) return null;
682
+ let inCode = false;
683
+ for (let i = 0; i < cur; i++) {
684
+ if (FENCE_RE.test(lines[i])) inCode = !inCode;
685
+ }
686
+ if (!inCode) return null;
687
+ if (!FENCE_RE.test(lines[cur + 1])) return null;
688
+ let off = 0;
689
+ for (let i = 0; i <= cur + 1; i++) off += lines[i].length + 1;
690
+ return { type: "jumpout", cursor: off };
691
+ }
692
+ if (key === "Tab" && !mods.ctrl && !mods.alt && !mods.meta) {
693
+ // Tab 缩进体系(v0.2.9):全部拦截——浏览器默认 Tab 会把焦点切出输入框。
694
+ // - 代码块内:光标处插两空格(既有行为)
695
+ // - 光标在列表/引用「标记头」内或行首空白区:行首插两空格(嵌套列表/引用
696
+ // ——列表续项后光标恰在标记后,Tab 即缩进成子列表)
697
+ // - 其余(行中间):光标处插两空格
698
+ // - Shift+Tab:行首删最多 2 个空白(反缩进);无缩进可删则只吞按键
699
+ const lines = String(value).slice(0, pos).split("\n");
700
+ let inCode = false;
701
+ for (let i = 0; i < lines.length - 1; i++) {
702
+ if (FENCE_RE.test(lines[i])) inCode = !inCode;
703
+ }
704
+ const curLine = lines[lines.length - 1];
705
+ const lineStart = pos - curLine.length;
706
+ if (inCode && !FENCE_RE.test(curLine)) {
707
+ return { type: "indent", insert: " " };
708
+ }
709
+ // 反缩进/标记头判定用「完整行」(光标前缀在光标处行首时是空串)
710
+ const text = String(value);
711
+ const nlIdx = text.indexOf("\n", lineStart);
712
+ const fullLine = text.slice(lineStart, nlIdx === -1 ? text.length : nlIdx);
713
+ if (mods.shift) {
714
+ const rm = /^[ \t]{1,2}/.exec(fullLine);
715
+ if (rm) return { type: "outdent", rangeStart: lineStart, rangeEnd: lineStart + rm[0].length };
716
+ return { type: "tab-noop" };
717
+ }
718
+ const hm = fullLine.match(LIST_RE) || fullLine.match(QUOTE_RE);
719
+ const headEnd = hm ? hm[0].length : (/^\s*/.exec(fullLine) || [""])[0].length;
720
+ if (pos - lineStart <= headEnd) {
721
+ return { type: "indent-at", at: lineStart, cursor: pos + 2 };
722
+ }
723
+ return { type: "indent", insert: " " };
724
+ }
725
+ return null;
726
+ }
727
+
728
+ /**
729
+ * 选区跨行时的 Tab/Shift+Tab:整块每行行首 ±2 空格(空行不缩进)。
730
+ * 返回 block-indent(整块替换,选区恢复为新块);
731
+ * Shift+Tab 无可删缩进 → tab-noop(吞键防焦点跳);
732
+ * Tab 空行块 → 退化为 indent(选区替换为两空格,行内 Tab 语义)。
733
+ */
734
+ function decideTabBlock(value, from, to, shift) {
735
+ const text = String(value);
736
+ let a = Math.max(0, Math.min(from, to));
737
+ let b = Math.min(Math.max(from, to), text.length);
738
+ a = Math.min(a, text.length);
739
+ const lineStartA = text.lastIndexOf("\n", Math.max(0, a - 1)) + 1;
740
+ const lastNl = text.indexOf("\n", b);
741
+ const blockEnd = lastNl === -1 ? text.length : lastNl;
742
+ const blockLines = text.slice(lineStartA, blockEnd).split("\n");
743
+ const newLines = blockLines.map((l) => (shift ? l.replace(/^[ \t]{1,2}/, "") : (l.trim() === "" ? l : " " + l)));
744
+ const changed = newLines.some((l, i) => l !== blockLines[i]);
745
+ if (shift && !changed) return { type: "tab-noop" };
746
+ if (!shift && !changed) return { type: "indent", insert: " " };
747
+ const insert = newLines.join("\n");
748
+ return { type: "block-indent", rangeStart: lineStartA, rangeEnd: blockEnd, insert, selStart: lineStartA, selEnd: lineStartA + insert.length };
749
+ }
750
+
751
+ function spanHit(line, col, re, preGroup) {
752
+ re.lastIndex = 0;
753
+ let m;
754
+ while ((m = re.exec(line))) {
755
+ const s = m.index + (preGroup ? m[1].length : 0);
756
+ if (col >= s && col <= m.index + m[0].length) return true;
757
+ }
758
+ return false;
759
+ }
760
+
761
+ function decideActiveMarks(value, pos) {
762
+ const text = String(value || "");
763
+ const s = Math.max(0, Math.min(pos == null ? 0 : pos, text.length));
764
+ const before = text.slice(0, s);
765
+ const lineStart = before.lastIndexOf("\n") + 1;
766
+ const nl = text.slice(s).indexOf("\n");
767
+ const lineEnd = nl === -1 ? text.length : s + nl;
768
+ const line = text.slice(lineStart, lineEnd);
769
+ const col = s - lineStart;
770
+ const marks = [];
771
+ if (spanHit(line, col, /(^|[^`])`([^`]+)`(?!`)/g, true)) marks.push("code");
772
+ if (spanHit(line, col, /\*\*([^*]+)\*\*/g, false)) marks.push("bold");
773
+ else if (spanHit(line, col, /(^|[^*])\*([^*\s][^*]*)\*(?!\*)/g, true)) marks.push("italic");
774
+ if (/^\s{0,3}([-*+]|\d+[.)])\s+/.test(line)) marks.push("list");
775
+ const beforeLines = before.split("\n");
776
+ let inCode = false;
777
+ for (let i = 0; i < beforeLines.length - 1; i++) {
778
+ if (FENCE_RE.test(beforeLines[i])) inCode = !inCode;
779
+ }
780
+ if (inCode && !FENCE_RE.test(line)) marks.push("block");
781
+ return marks;
782
+ }
783
+
784
+ function isCursorInCodeBlock(value, pos) {
785
+ const text = String(value || "");
786
+ const s = Math.max(0, Math.min(pos == null ? 0 : pos, text.length));
787
+ const before = text.slice(0, s);
788
+ const beforeLines = before.split("\n");
789
+ let inCode = false;
790
+ for (let i = 0; i < beforeLines.length - 1; i++) {
791
+ if (FENCE_RE.test(beforeLines[i])) inCode = !inCode;
792
+ }
793
+ return inCode && !FENCE_RE.test(beforeLines[beforeLines.length - 1]);
794
+ }
795
+
796
+ function decidePasteWrap(value, pos, pasted) {
797
+ const text = String(pasted || "");
798
+ if (!text) return null;
799
+ const lines = text.split("\n");
800
+ if (lines.length <= PASTE_WRAP_MAX_LINES && text.length <= PASTE_WRAP_MAX_CHARS) return null;
801
+ if (!guessLang(text)) return null;
802
+ if (isCursorInCodeBlock(value, pos)) return null;
803
+ if (/^(`{3,}|~{3,})/m.test(text)) return null;
804
+ const before = String(value || "").slice(0, pos == null ? 0 : pos);
805
+ const pad = before && !before.endsWith("\n") ? "\n" : "";
806
+ return { insert: pad + "```\n" + text + "\n```\n", pad: pad, lines: lines.length };
807
+ }
808
+
809
+ function stateKey(st) {
810
+ return st.inCode + "|" + st.lang + "|" + (st.fenceOpen ? "o" : "") + (st.fenceClose ? "c" : "") + "|" + (st.kind ? st.kind.type : "");
811
+ }
812
+
813
+ function isLightColor(colorStr) {
814
+ const m = String(colorStr || "").match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
815
+ if (!m) return false;
816
+ const lum = 0.299 * (+m[1]) + 0.587 * (+m[2]) + 0.114 * (+m[3]);
817
+ return lum < 128;
818
+ }
819
+ //#endregion
820
+
821
+ //#region dsh-composer-live: injected styles
822
+ function styleText() {
823
+ return (
824
+ "#" + STYLE_ID + "{}" +
825
+ // 常驻工具栏(absolute 横贯输入卡顶部):占位由 input/placeholder 的
826
+ // padding-top 同步让出(overlay 的 padding 每次渲染从 input 复制)。
827
+ "." + TOOLBAR + "{position:sticky;top:0;left:0;right:0;z-index:60;display:flex;align-items:center;box-sizing:border-box;height:38px;padding:0 10px;gap:2px;background:rgba(28,30,36,.95);border-bottom:1px solid rgba(127,127,127,.16);}" +
828
+ // sticky 工具栏钉滚动视口顶(v0.2.8):内容从其下方滚过永不重叠;附件栏在
829
+ // 滚动容器外,scroll 顶=附件栏底,天然让位(v0.2.7 的让位变量退役)。背景色
830
+ // 必须不透明——滚动内容从下方经过(用户三报「工具栏透明」的根治)。
831
+ "body.dsh-cl-light ." + TOOLBAR + "{background:rgba(250,251,253,.97);border-bottom-color:rgba(0,0,0,.08);}" +
832
+ "." + TOOLBAR + " [data-cl=\"expand\"]{margin-left:auto;}" +
833
+
834
+ // 编辑层与占位层让出工具栏一行(38px 工具栏 + 6px 呼吸)
835
+ CARD_SEL + " " + INPUT_SEL + "{padding-top:" + BAR_RESERVE + " !important;}" +
836
+ CARD_SEL + " " + PLACEHOLDER_SEL + "{padding-top:" + BAR_RESERVE + " !important;box-sizing:border-box;}" +
837
+ // 透明化:只隐藏文字「绘制」(-webkit-text-fill-color),color 属性保留
838
+ // ——caret 颜色、官方 hint ::after、chip 恢复全靠 color 不动。
839
+ // 提层:编辑器自身 z-index:1 画在 overlay(z:0)之上——caret/选区是编辑器
840
+ // 内容的一部分,随层自然盖在 overlay 上(caret 可见),而文字透明不遮挡
841
+ // overlay 的渲染文字。不能用「overlay 压 z-index:-1」的思路:负 z 会掉到
842
+ // 输入框背景之下,渲染文字整体不可见。
843
+ INPUT_SEL + "." + HOST + "{-webkit-text-fill-color:transparent;caret-color:#67a0fe;position:relative;z-index:1;}" +
844
+ // 选中态:Chromium 会用 ::selection 的默认反色把透明文字重绘出来(白字),
845
+ // 与 overlay 渲染文字形成双影——选中文字保持隐形(color:transparent),
846
+ // 但必须同时显式给 background:::selection 规则一旦匹配,未声明的属性
847
+ // 取初始值(transparent),会把浏览器默认高亮一并洗掉(选中无色)。
848
+ SCROLL_SEL + "{--dsh-cl-sel-bg:rgba(103,160,254,.35);}" +
849
+ SCROLL_SEL + ".dsh-cl-light{--dsh-cl-sel-bg:rgba(59,130,246,.28);}" +
850
+ INPUT_SEL + "." + HOST + "::selection{color:transparent;background:var(--dsh-cl-sel-bg);}" +
851
+ INPUT_SEL + "." + HOST + " ::selection{color:transparent;background:var(--dsh-cl-sel-bg);}" +
852
+ // chip(@ 引用胶囊宿主)与热词装饰恢复显示;hint 的 ::after 恢复
853
+ INPUT_SEL + "." + HOST + " " + CHIP_HOST_SEL + "{-webkit-text-fill-color:currentColor;}" +
854
+ INPUT_SEL + "." + HOST + " p::after{-webkit-text-fill-color:currentColor;}" +
855
+ // 含 chip 段落整体恢复(KEEP,JS 打 class):该段 overlay 只放空占位
856
+ INPUT_SEL + "." + HOST + " p." + KEEP + "{-webkit-text-fill-color:currentColor;}" +
857
+ // 代码段(编辑器)等宽:整框切换,与 overlay 渲染段同字体变量
858
+ INPUT_SEL + "." + MONO_ALL + "{font-family:var(--dsh-cl-code-font,var(--ds-font-family-code,monospace)) !important;}" +
859
+ // 按钮
860
+ ".dsh-cl-btn{display:inline-flex;align-items:center;justify-content:center;width:27px;height:27px;border-radius:7px;border:1px solid transparent;background:transparent;color:inherit;cursor:pointer;padding:0;line-height:0;transition:background .15s ease,transform .1s ease;}" +
861
+ ".dsh-cl-btn:hover{background:rgba(127,127,127,.18);}" +
862
+ ".dsh-cl-btn:active{transform:scale(.92);background:rgba(127,127,127,.28);}" +
863
+ ".dsh-cl-btn:focus-visible{outline:2px solid #3b82f6;outline-offset:1px;}" +
864
+ ".dsh-cl-btn svg{width:15px;height:15px;opacity:.85;}" +
865
+ ".dsh-cl-btn:hover svg{opacity:1;}" +
866
+ ".dsh-cl-btn.dsh-cl-active{background:rgba(59,130,246,.16);color:#3b82f6;}" +
867
+ // 展开态:滚动容器定高 70vh(只有 max-height 时高度仍由内容决定——
868
+ // 内容少的瞬间点展开纹丝不动,用户感知「按钮失效」)
869
+ "." + EXPANDED + "{max-height:70vh !important;height:70vh !important;}" +
870
+ // 自建实时渲染层:铺满 grow、z:0(编辑器 z:1 盖其上,caret/选区可见)、点击穿透
871
+ "." + LIVE + "{position:absolute;inset:0;z-index:0;pointer-events:none;overflow:hidden;}" +
872
+ "." + LIVE + " div{margin:var(--dsh-cl-line-margin,0);}" +
873
+ // 哨兵:零尺寸不占布局,读官方输入框继承字体链
874
+ "." + SENTINEL + "{position:absolute;width:0;height:0;overflow:hidden;pointer-events:none;}" +
875
+ // 行内渲染标签样式(作用于 overlay)。
876
+ // 粗体/标题一律 paint-only(-webkit-text-stroke 伪粗),font-weight 变化
877
+ // 会改字符 advance——渲染行与编辑器透明文字错位,光标漂移。
878
+ "." + LIVE + " strong{font-weight:inherit;-webkit-text-stroke:.45px currentColor;}" +
879
+ "." + LIVE + " em{font-style:italic;}" +
880
+ "." + LIVE + " del{text-decoration:line-through;opacity:.75;}" +
881
+ "." + LIVE + " a{color:#3b82f6;text-decoration:none;}" +
882
+ "." + LIVE + " code{font-family:inherit;color:#3b82f6;background:rgba(59,130,246,.13);border-radius:5px;padding:1px 5px;margin:0 -5px;}" +
883
+ // 行内格式的标记字符(**、`、~~、[]()):淡化但保留占位(字符级对齐)
884
+ ".dsh-cl-mk{color:rgba(127,127,127,.5);}" +
885
+
886
+ ".dsh-cl-hmark{color:#3b82f6;font-weight:inherit;-webkit-text-stroke:.45px currentColor;}" +
887
+ ".dsh-cl-heading{font-weight:inherit;-webkit-text-stroke:.45px currentColor;}" +
888
+ ".dsh-cl-qmark{color:#3b82f6;}" +
889
+ ".dsh-cl-quote{position:relative;z-index:0;}" +
890
+ ".dsh-cl-quote::before{content:\"\";position:absolute;inset:0 -12px;background:rgba(127,127,127,.10);border-left:3px solid #3b82f6;z-index:-1;}" +
891
+ ".dsh-cl-lmark{color:#3b82f6;}" +
892
+ ".dsh-cl-tmark{color:#8b949e;}" +
893
+ ".dsh-cl-img{background:rgba(59,130,246,.13);color:#3b82f6;border-radius:5px;padding:1px 5px;margin:0 -5px;}" +
894
+ // 代码段 overlay 行:等宽 + 伪元素外扩背景(GitHub Dark/Light 两套变量)
895
+ SCROLL_SEL + "{--dsh-cl-tok-kw:#ff7b72;--dsh-cl-tok-str:#a5d6ff;--dsh-cl-tok-com:#8b949e;--dsh-cl-tok-num:#79c0ff;--dsh-cl-tok-fn:#d2a8ff;--dsh-cl-tok-lit:#79c0ff;--dsh-cl-code-bg:rgba(13,17,23,.92);--dsh-cl-lang-fg:rgba(249,250,251,.9);}" +
896
+ SCROLL_SEL + ".dsh-cl-light{--dsh-cl-tok-kw:#cf222e;--dsh-cl-tok-str:#0a3069;--dsh-cl-tok-com:#6e7781;--dsh-cl-tok-num:#0550ae;--dsh-cl-tok-fn:#8250df;--dsh-cl-tok-lit:#0550ae;--dsh-cl-code-bg:rgba(246,248,250,.96);--dsh-cl-lang-fg:rgba(31,35,40,.85);}" +
897
+ ".dsh-cl-cline{position:relative;z-index:0;}" +
898
+ ".dsh-cl-cline{font-family:var(--dsh-cl-code-font,var(--ds-font-family-code,monospace));}" +
899
+ ".dsh-cl-cline::before{content:\"\";position:absolute;inset:0 -12px;background:var(--dsh-cl-code-bg);border-radius:0;z-index:-1;}" +
900
+ ".dsh-cl-cline.dsh-cl-fence-open::before{border-radius:10px 10px 0 0;inset:2px -14px 0;}" +
901
+ ".dsh-cl-cline.dsh-cl-fence-close::before{border-radius:0 0 10px 10px;inset:0 -14px 2px;}" +
902
+ ".dsh-cl-fch{visibility:hidden;}" +
903
+ ".dsh-cl-lang{position:absolute;right:26px;font-size:10px;line-height:1;padding:3px 7px;border-radius:5px;background:rgba(127,127,127,.3);border:1px solid rgba(255,255,255,.12);letter-spacing:.07em;text-transform:uppercase;color:var(--dsh-cl-lang-fg);pointer-events:none;}" +
904
+ // ===== 字符锚定渲染(v0.2):定位字符段 + 背景块 =====
905
+ // 字符段:绝对定位画在编辑器字符 rect 上;字体继承 overlay(syncMetrics
906
+ // 复制官方度量);样式类只改 paint(伪粗/染色/胶囊背景外扩),零 advance 变化。
907
+ ".dsh-cl-c{position:absolute;white-space:pre;pointer-events:none;color:inherit;font-family:inherit;}" +
908
+ ".dsh-cl-c.dsh-cl-codefont{font-family:var(--dsh-cl-code-font,var(--ds-font-family-code,monospace));}" +
909
+ ".dsh-cl-c.dsh-cl-strong,.dsh-cl-c.dsh-cl-heading{font-weight:inherit;-webkit-text-stroke:.45px currentColor;}" +
910
+ ".dsh-cl-c.dsh-cl-em{font-style:italic;}" +
911
+ // cd/img 胶囊背景外扩:横向 padding+负 margin 互相抵消(advance 零变化);
912
+ // 垂直同理必须 margin:-1px 抵消 padding-top:1px——段是绝对定位块,
913
+ // 只补横向会让段内文字整体下移 1px(E2E INV-3 实测 dy 恒 +1.00)。
914
+ ".dsh-cl-c.dsh-cl-cd{color:#3b82f6;background:rgba(59,130,246,.13);border-radius:5px;padding:1px 5px;margin:-1px -5px;}" +
915
+ ".dsh-cl-c.dsh-cl-dl{text-decoration:line-through;opacity:.75;}" +
916
+ ".dsh-cl-c.dsh-cl-lk{color:#3b82f6;}" +
917
+ ".dsh-cl-c.dsh-cl-mk{color:rgba(127,127,127,.5);}" +
918
+ ".dsh-cl-c.dsh-cl-hmark{color:#3b82f6;}" +
919
+ ".dsh-cl-c.dsh-cl-qmark{color:#3b82f6;}" +
920
+ ".dsh-cl-c.dsh-cl-lmark{color:#3b82f6;}" +
921
+ // 任务列表勾选标记([ ]/[x] 三字符胶囊;done 绿底+行内容划线由 dsh-cl-dl)
922
+ ".dsh-cl-c.dsh-cl-task{color:rgba(127,127,127,.55);background:rgba(59,130,246,.12);border-radius:4px;padding:1px 3px;margin:-1px -3px;}" +
923
+ ".dsh-cl-c.dsh-cl-task-done{color:#16a34a;background:rgba(34,197,94,.18);}" +
924
+ ".dsh-cl-c.dsh-cl-tmark{color:#8b949e;}" +
925
+ ".dsh-cl-c.dsh-cl-img{color:#3b82f6;background:rgba(59,130,246,.13);border-radius:5px;padding:1px 5px;margin:-1px -5px;}" +
926
+ ".dsh-cl-c.dsh-cl-tok-kw{color:var(--dsh-cl-tok-kw);}" +
927
+ ".dsh-cl-c.dsh-cl-tok-str{color:var(--dsh-cl-tok-str);}" +
928
+ ".dsh-cl-c.dsh-cl-tok-com{color:var(--dsh-cl-tok-com);}" +
929
+ ".dsh-cl-c.dsh-cl-tok-num{color:var(--dsh-cl-tok-num);}" +
930
+ ".dsh-cl-c.dsh-cl-tok-fn{color:var(--dsh-cl-tok-fn);}" +
931
+ ".dsh-cl-c.dsh-cl-tok-lit{color:var(--dsh-cl-tok-lit);}" +
932
+ // 背景块:代码行(外扩已含在 JS 定位里)/ 引用行
933
+ ".dsh-cl-bg{position:absolute;background:var(--dsh-cl-code-bg);pointer-events:none;}" +
934
+ ".dsh-cl-bg-open{border-radius:10px 10px 0 0;}" +
935
+ ".dsh-cl-bg-close{border-radius:0 0 10px 10px;}" +
936
+ ".dsh-cl-qbg{position:absolute;background:rgba(127,127,127,.10);border-left:3px solid #3b82f6;pointer-events:none;}" +
937
+ ".dsh-cl-tok-kw{color:var(--dsh-cl-tok-kw);}" +
938
+ ".dsh-cl-tok-str{color:var(--dsh-cl-tok-str);}" +
939
+ ".dsh-cl-tok-com{color:var(--dsh-cl-tok-com);}" +
940
+ ".dsh-cl-tok-num{color:var(--dsh-cl-tok-num);}" +
941
+ ".dsh-cl-tok-fn{color:var(--dsh-cl-tok-fn);}" +
942
+ ".dsh-cl-tok-lit{color:var(--dsh-cl-tok-lit);}" +
943
+ // 选区浮动格式条:body 级 fixed(避免被输入卡 overflow 裁剪)
944
+ "." + FLOAT + "{position:fixed;z-index:80;display:none;align-items:center;gap:2px;padding:3px;border-radius:9px;background:rgba(28,30,36,.95);border:1px solid rgba(255,255,255,.12);box-shadow:0 4px 14px rgba(0,0,0,.28);}" +
945
+ "." + FLOAT + ".dsh-cl-float-on{display:flex;}" +
946
+ "body.dsh-cl-light ." + FLOAT + "{background:rgba(250,251,253,.97);border-color:rgba(0,0,0,.08);}" +
947
+ // 轻提示
948
+ "." + TOAST + "{position:absolute;top:-40px;left:50%;transform:translateX(-50%);z-index:70;background:rgba(28,30,36,.93);color:rgba(240,242,246,.95);font-size:12px;line-height:1;padding:8px 14px;border-radius:8px;pointer-events:none;white-space:nowrap;box-shadow:0 4px 14px rgba(0,0,0,.28);border:1px solid rgba(255,255,255,.09);opacity:0;transition:opacity .18s ease;}" +
949
+ "." + TOAST + ".dsh-cl-toast-on{opacity:1;}" +
950
+ SCROLL_SEL + ".dsh-cl-light ." + TOAST + "{background:rgba(250,251,253,.96);color:rgba(28,32,40,.92);border-color:rgba(0,0,0,.08);}" +
951
+ ""
952
+ );
953
+ }
954
+ //#endregion
955
+
956
+ //#region dsh-composer-live: DOM 工具
957
+ function findCard() {
958
+ return document.querySelector(CARD_SEL);
959
+ }
960
+
961
+ function toast(card, message) {
962
+ if (!card) return;
963
+ let el = card.querySelector("." + TOAST);
964
+ if (!el) {
965
+ el = document.createElement("div");
966
+ el.className = TOAST;
967
+ el.setAttribute("aria-hidden", "true");
968
+ card.appendChild(el);
969
+ }
970
+ el.textContent = message;
971
+ void el.offsetWidth;
972
+ el.classList.add("dsh-cl-toast-on");
973
+ clearTimeout(el.__dshClToastTimer);
974
+ el.__dshClToastTimer = setTimeout(() => el.classList.remove("dsh-cl-toast-on"), 2500);
975
+ }
976
+
977
+ /** aria-label 命中提示集合的按钮查找(限定 root 内)。 */
978
+ function findButtonByHint(root, hints) {
979
+ if (!root || !root.querySelectorAll) return null;
980
+ for (const b of root.querySelectorAll("button")) {
981
+ const label = b.getAttribute("aria-label") || "";
982
+ for (const h of hints) {
983
+ if (label === h || label.indexOf(h) !== -1) return b;
984
+ }
985
+ }
986
+ return null;
987
+ }
988
+
989
+ /** 官方候选建议/弹层是否开着(开着时 Enter/Tab/Esc/粘贴一律归官方)。 */
990
+ function menuOpen(card) {
991
+ if (!card || !card.querySelector) return false;
992
+ return card.querySelector(MENU_SEL) !== null;
993
+ }
994
+
995
+ function iconSvg(kind) {
996
+ const S = '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">';
997
+ const icons = {
998
+ bold: '<path d="M4 2.5h4.2a2.4 2.4 0 0 1 0 4.8H4zm0 4.8h4.8a2.6 2.6 0 0 1 0 5.2H4z"/>',
999
+ italic: '<path d="M10.5 2.5h-4M9.5 13.5h-4M9.8 2.5L6.2 13.5"/>',
1000
+ code: '<path d="M9.5 4L5.5 8l4 4M13 4L9 8l4 4"/>',
1001
+ link: '<path d="M6.5 9.5l3-3M5 7L3.8 8.2a2.4 2.4 0 0 0 3.4 3.4L8.4 10.4M11 9l1.2-1.2a2.4 2.4 0 0 0-3.4-3.4L7.6 5.6"/>',
1002
+ list: '<path d="M5.5 4h8M5.5 8h8M5.5 12h8"/><circle cx="2.8" cy="4" r=".9" fill="currentColor" stroke="none"/><circle cx="2.8" cy="8" r=".9" fill="currentColor" stroke="none"/><circle cx="2.8" cy="12" r=".9" fill="currentColor" stroke="none"/>',
1003
+ block: '<rect x="2" y="2.5" width="12" height="11" rx="2"/><path d="M5.5 6.5L4 8l1.5 1.5M10.5 6.5L12 8l-1.5 1.5"/>',
1004
+ expand: '<path d="M3 6V3h3M13 10v3h-3M3 10v3h3M13 6V3h-3"/>',
1005
+ collapse: '<path d="M6 10v3H3M10 6V3h3M6 6V3H3M10 10v3h3"/>'
1006
+ };
1007
+ return S + (icons[kind] || icons.expand) + "</svg>";
1008
+ }
1009
+ //#endregion
1010
+
1011
+ //#region dsh-composer-live: 编辑器「投影文本」与 DOM Selection 映射
1012
+ /**
1013
+ * 0.1.5 编辑器的真实 DOM 形态(实测于 dsh 0.1.5 web 应用):**单个 <p> + <br>(Lexical
1014
+ * LineBreakNode)分 行**,文本在 <span data-lexical-text> 里,chip 宿主是
1015
+ * <span data-composer-chip|data-composer-text-ref>。setDraft(程序化灌入)
1016
+ * 才会产生多个 <p>。因此「行」按 br 与 p 边界切,不按 p 元素切。
1017
+ *
1018
+ * buildAnchors 深度遍历编辑器根,产出锚表:
1019
+ * {kind:'text', node, len} 文本节点(投影=nodeValue)
1020
+ * {kind:'br', len:1} 行分隔(投影=\n)
1021
+ * {kind:'chip', el, len:1} chip 宿主(投影=,与官方 detectText 同构)
1022
+ * {kind:'para', len:1} p 元素边界(投影=\n)
1023
+ * 投影文本 = 锚表顺序拼接;行序列 = 投影文本 split("\n")。
1024
+ * 光标/选区与投影 offset 的互查全部基于锚表。
1025
+ */
1026
+ function buildAnchors(input) {
1027
+ const anchors = [];
1028
+ if (!input) return anchors;
1029
+ let firstPara = true;
1030
+ const visit = (node) => {
1031
+ for (let c = node.firstChild; c; c = c.nextSibling) {
1032
+ if (c.nodeType === Node.TEXT_NODE) {
1033
+ if (c.nodeValue.length > 0) anchors.push({ kind: "text", node: c, len: c.nodeValue.length });
1034
+ } else if (c.nodeType === Node.ELEMENT_NODE) {
1035
+ if (c.tagName === "BR") {
1036
+ // Lexical 的行尾 managed br(data-lexical-managed-linebreak)是
1037
+ // 「让空行可见」的占位标记,不产生新行——不计为行分隔。
1038
+ if (c.getAttribute && c.getAttribute("data-lexical-managed-linebreak") != null) continue;
1039
+ anchors.push({ kind: "br", el: c, len: 1 });
1040
+ continue;
1041
+ }
1042
+ if (c.tagName === "P") {
1043
+ if (!firstPara) anchors.push({ kind: "para", el: c, len: 1 });
1044
+ firstPara = false;
1045
+ visit(c);
1046
+ continue;
1047
+ }
1048
+ if (c.matches && c.matches(CHIP_HOST_SEL)) { anchors.push({ kind: "chip", el: c, len: 1 }); continue; }
1049
+ // 其余元素(B/I/STRONG/DIV 等富文本粘贴产物、装饰壳)一律继续
1050
+ // 下钻收集文字——「忽略内容」会把粘贴的富文本结构整个漏掉:锚表
1051
+ // 空转 → 渲染层空白 + 透明化已生效 = 大段粘贴「一片空白」(文字
1052
+ // 在编辑器里透明不可见,发送时却在)。chip 宿主已在上面拦截。
1053
+ visit(c);
1054
+ }
1055
+ }
1056
+ };
1057
+ visit(input);
1058
+ return anchors;
1059
+ }
1060
+
1061
+ /** 锚表 → 投影文本。 */
1062
+ function anchorsText(anchors) {
1063
+ let s = "";
1064
+ for (const a of anchors) {
1065
+ if (a.kind === "text") s += a.node.nodeValue;
1066
+ else if (a.kind === "chip") s += CHIP_CHAR;
1067
+ else s += "\n";
1068
+ }
1069
+ return s;
1070
+ }
1071
+
1072
+ /**
1073
+ * DOM Selection 某端(node, offset)→ 投影 offset。锚表顺序累计,端点在
1074
+ * 文本节点上取其锚起点 + offset;端点在元素上(选区端在元素边界)按
1075
+ * 「该元素子树首个文本锚起点」近似。不在编辑器内返回 null。
1076
+ */
1077
+ function offsetFromSelection(input, anchors, node, off) {
1078
+ if (!node || !input.contains(node)) return null;
1079
+ let acc = 0;
1080
+ for (const a of anchors) {
1081
+ if (a.kind === "text") {
1082
+ if (a.node === node) return acc + Math.min(off, a.len);
1083
+ if (a.node.parentElement === node) return acc; // 端点在父元素 offset=0 的保守近似
1084
+ acc += a.len;
1085
+ } else {
1086
+ if (a.el === node) return acc; // 端点恰在 br/chip 元素上
1087
+ if (node.nodeType === Node.ELEMENT_NODE && (a.kind === "chip") && a.el.contains(node)) return acc;
1088
+ acc += 1;
1089
+ }
1090
+ }
1091
+ return acc;
1092
+ }
1093
+
1094
+ /** DOM Selection 的 anchor/focus 端 → 投影 offset。 */
1095
+ function domOffsetOf(input, anchors, useAnchor) {
1096
+ const sel = window.getSelection();
1097
+ if (!sel || sel.rangeCount === 0) return null;
1098
+ const node = useAnchor ? sel.anchorNode : sel.focusNode;
1099
+ const off = useAnchor ? sel.anchorOffset : sel.focusOffset;
1100
+ return offsetFromSelection(input, anchors, node, off);
1101
+ }
1102
+
1103
+ /** 投影 offset → DOM Range 起点(沿锚表走到目标;br/chip 处取其后)。 */
1104
+ function rangeAtOffset(input, anchors, absOffset) {
1105
+ let acc = 0;
1106
+ const range = document.createRange();
1107
+ for (const a of anchors) {
1108
+ if (a.kind === "text") {
1109
+ if (absOffset <= acc + a.len) {
1110
+ try { range.setStart(a.node, Math.max(0, absOffset - acc)); } catch (err) { return null; }
1111
+ return range;
1112
+ }
1113
+ acc += a.len;
1114
+ } else {
1115
+ acc += 1;
1116
+ if (absOffset <= acc) {
1117
+ try { range.setStartAfter(a.el); } catch (err) { return null; }
1118
+ return range;
1119
+ }
1120
+ }
1121
+ }
1122
+ // 超尾:落在最后一个文本锚尾
1123
+ for (let i = anchors.length - 1; i >= 0; i--) {
1124
+ if (anchors[i].kind === "text") {
1125
+ try { range.setStart(anchors[i].node, anchors[i].len); } catch (err) { return null; }
1126
+ return range;
1127
+ }
1128
+ }
1129
+ try { range.selectNodeContents(input); range.collapse(false); } catch (err) { return null; }
1130
+ return range;
1131
+ }
1132
+
1133
+ /** 设置 DOM Selection(投影文本 offset 区间)。 */
1134
+ function setSelectionByOffset(input, start, end) {
1135
+ const anchors = buildAnchors(input);
1136
+ const ra = rangeAtOffset(input, anchors, start);
1137
+ const rb = end == null || end === start ? ra : rangeAtOffset(input, anchors, end);
1138
+ if (!ra || !rb) return;
1139
+ const sel = window.getSelection();
1140
+ sel.removeAllRanges();
1141
+ const full = document.createRange();
1142
+ full.setStart(ra.startContainer, ra.startOffset);
1143
+ full.setEnd(rb.startContainer, rb.startOffset);
1144
+ sel.addRange(full);
1145
+ }
1146
+ //#endregion
1147
+
1148
+ //#region dsh-composer-live: 渲染引擎
1149
+ /** 从编辑层复制影响排版与对齐的度量到 overlay(签名变化才重写)。
1150
+ * font/行高度量优先从编辑器首个 <p>(行的真实排版容器)读——行高用 p 的
1151
+ * computed line-height(行盒高,不是字形高;合成栈的 normal 行高不同但
1152
+ * overlay 行 div 走同一 computed 值即可对齐);padding 从 input 读;
1153
+ * 首行常量垂直偏差(实测 ~2px)用动态 top 偏移吸收。 */
1154
+ const METRIC_KEYS = ["fontFamily", "fontSize", "fontWeight", "lineHeight", "letterSpacing",
1155
+ "tabSize", "whiteSpace", "wordBreak", "overflowWrap", "wordWrap",
1156
+ "boxSizing", "textAlign", "textIndent"];
1157
+ function syncMetrics(input, ov) {
1158
+ const cs = getComputedStyle(input);
1159
+ const fp = input.querySelector(":scope > p");
1160
+ const fc = fp ? getComputedStyle(fp) : cs;
1161
+ let color = cs.color;
1162
+ if (!color || color === "transparent" || color === "rgba(0, 0, 0, 0)") {
1163
+ color = getComputedStyle(input.parentElement).color;
1164
+ }
1165
+ const sig = [fc.fontFamily, fc.fontSize, fc.fontWeight, fc.lineHeight, cs.padding, color].join("|");
1166
+ if (ov.__dshClSig === sig) return color;
1167
+ for (const k of METRIC_KEYS) ov.style[k] = fc[k];
1168
+ ov.style.padding = cs.padding;
1169
+ ov.style.setProperty("--dsh-cl-line-margin", fc.marginTop + " 0 " + fc.marginBottom);
1170
+ ov.style.color = color;
1171
+ ov.__dshClSig = sig;
1172
+ return color;
1173
+ }
1174
+
1175
+ /**
1176
+ * 主渲染(幂等;所有同步路径必经):
1177
+ * 锚表收集(br/p 切行)→ 增量渲染 overlay 行 → 编辑器 p 打 class(chip 恢复
1178
+ * 优先 / 代码行等宽)→ 度量/主题/工具栏高亮。
1179
+ *
1180
+ * 行对齐说明:编辑器是单 p + br 分行,行高由 font/line-height 决定——overlay
1181
+ * 与编辑器同字体同行高(syncMetrics 复制),行高天然一致,无需逐行高度对齐。
1182
+ */
1183
+ function render(card) {
1184
+ const input = card.querySelector(INPUT_SEL);
1185
+ const ov = card.querySelector("." + LIVE);
1186
+ if (!input || !ov) return;
1187
+ fixTriggerMenuPos(card); // 官方菜单悬空兜底(图片附件在场时 @// 菜单定位)
1188
+ const editable = input.getAttribute("contenteditable") === "true";
1189
+ input.classList.toggle(HOST, editable);
1190
+ if (!editable) {
1191
+ ov.innerHTML = "";
1192
+ ov.__dshClLineCache = null;
1193
+ return;
1194
+ }
1195
+ const anchors = buildAnchors(input);
1196
+ const src = anchorsText(anchors);
1197
+ const lines = src.split("\n");
1198
+ const caret = domOffsetOf(input, anchors, true);
1199
+ const cursorLine = caret == null ? -1 : src.slice(0, caret).split("\n").length - 1;
1200
+
1201
+ // 合成字体栈(拉丁=代码等宽,中文=界面栈)写入 card 变量
1202
+ const sentinel = card.__dshClSentinel;
1203
+ if (sentinel && sentinel.isConnected) {
1204
+ const sc = getComputedStyle(sentinel);
1205
+ const merged = buildCodeFont(sc.getPropertyValue("--ds-font-family-code"), sc.fontFamily);
1206
+ if (card.__dshClFontSig !== merged) {
1207
+ card.__dshClFontSig = merged;
1208
+ card.style.setProperty("--dsh-cl-code-font", merged);
1209
+ }
1210
+ }
1211
+
1212
+ // 编辑器 class(打点在测量前——等宽打点影响布局,先定型再测量):
1213
+ // - KEEP(chip 段恢复)段落级
1214
+ // - 等宽「整框」级:文档含已闭合代码块 → input 整框切等宽、渲染全段同栈
1215
+ // (v0.1.x 同策略)。行级等宽(MONO_SPAN)在「字面 \n」形态物理不可行:
1216
+ // 大段粘贴是单文本锚跨全部行,class 只能打锚 parent(波及整锚),且打点
1217
+ // 循环的行号推进对字面 \n 会全部落 0(v0.2.3 只修了测量侧)。整框两侧
1218
+ // 恒同字体,测量 rect 自动跟随。
1219
+ const states = computeBlockStates(lines);
1220
+ const ps = input.querySelectorAll(":scope > p");
1221
+ const hasChip = lines.some(paraHasChip);
1222
+ for (const p of ps) {
1223
+ p.classList.toggle(KEEP, hasChip);
1224
+ }
1225
+ const hasCode = states.some((st) => st.inCode);
1226
+ input.classList.toggle(MONO_ALL, hasCode);
1227
+
1228
+ // 度量与主题(字符渲染的字体继承 overlay——syncMetrics 复制的度量仍是基准)
1229
+ const scrollEl = card.querySelector(SCROLL_SEL);
1230
+ const color = syncMetrics(input, ov);
1231
+ if (scrollEl) {
1232
+ scrollEl.classList.toggle("dsh-cl-light", isLightColor(color));
1233
+ document.body.classList.toggle("dsh-cl-light", isLightColor(color));
1234
+ }
1235
+
1236
+ // 字符锚定渲染:逐字符测量编辑器 rect,渲染字符绝对定位画在同一坐标。
1237
+ // 三类兜底(都回退官方显示——移除透明化,保证任何情况都有文字可读;
1238
+ // 官方文字与渲染段同字体同坐标,来回切换无视觉跳变,下一轮渲染自愈):
1239
+ // ① 锚表漏收检测:编辑器有文字但投影文本全空(未来官方结构变化/未知
1240
+ // 元素吞内容)——继续渲染就是「一片空白」;
1241
+ // ② 渲染抛错:测量/解析异常时 overlay 停在空白态 + 透明化已生效,同样空白;
1242
+ // ③ 批量插入中间态:粘贴等大段内容进入的窗口期(官方插入是异步的,渲染
1243
+ // 尚未跟上),「编辑器已有字但渲染段为 0」——此前用户看到的「粘贴后
1244
+ // 一片空白」正是这个约 1 秒的空窗(诊断实测:粘贴 1 秒后渲染正常)。
1245
+ const fallbackToOfficial = () => {
1246
+ input.classList.remove(HOST);
1247
+ input.classList.remove(MONO_ALL);
1248
+ input.querySelectorAll("." + KEEP).forEach((el) => el.classList.remove(KEEP));
1249
+ ov.innerHTML = "";
1250
+ };
1251
+ const hasContent = input.textContent.replace(//g, "").trim() !== "";
1252
+ if (hasContent && src.replace(//g, "").trim() === "") {
1253
+ fallbackToOfficial();
1254
+ } else {
1255
+ try {
1256
+ renderCharOverlay(ov, input, anchors, lines, states);
1257
+ if (hasContent && ov.querySelectorAll(".dsh-cl-c").length === 0) fallbackToOfficial();
1258
+ } catch (err) {
1259
+ fallbackToOfficial();
1260
+ }
1261
+ }
1262
+
1263
+ // 工具栏高亮
1264
+ if (card.__dshClBtns && caret != null) {
1265
+ const marks = decideActiveMarks(src, caret);
1266
+ for (const kind in card.__dshClBtns) {
1267
+ card.__dshClBtns[kind].classList.toggle("dsh-cl-active", marks.indexOf(kind) !== -1);
1268
+ }
1269
+ }
1270
+ card.__dshClSrc = src;
1271
+ card.__dshClCaret = caret;
1272
+ }
1273
+ //#endregion
1274
+
1275
+ //#region dsh-composer-live: 插入通道
1276
+ /**
1277
+ * 文本插入(单行/多行统一):**合成 beforeinput 事件** → Lexical 原生编辑
1278
+ * 管线(保撤销栈)。一律 setTimeout(0) 延后执行。
1279
+ *
1280
+ * 四个实测坑(Chromium + Lexical 0.49):
1281
+ * ① **keydown 处理器内执行编辑类 execCommand 静默失败**(返回 true 零效果)
1282
+ * ——必须延后到事件处理完(setTimeout 0 实测生效)。
1283
+ * ② execCommand("insertText") 对含 \n 的文本静默失败。
1284
+ * ③ 「合成 paste 事件」复用官方 PASTE_COMMAND 也不行:官方 handler 走
1285
+ * SessionInputShell.paste → selection.insertText(clean),Lexical 的
1286
+ * insertText 把 \n 当普通字符插进同一文本节点。
1287
+ * ④ 唯一全通的通道 = 合成 beforeinput:inputType=insertText(带 data)插
1288
+ * 文本、inputType=insertParagraph 插换行(Lexical LineBreakNode,即官方
1289
+ * Shift+Enter 的产物)。两者 Lexical 都异步 commit——效果要下一帧才可见。
1290
+ */
1291
+ function insertInline(input, text) {
1292
+ setTimeout(() => {
1293
+ try {
1294
+ const s = String(text);
1295
+ if (s.indexOf("\n") === -1) {
1296
+ // 单行:execCommand(keydown 处理器外实测有效;合成 beforeinput
1297
+ // 的 insertText 在 keydown 后的 setTimeout 里偶发不生效——Tab 缩进实测)
1298
+ document.execCommand("insertText", false, s);
1299
+ return;
1300
+ }
1301
+ const lines = s.split("\n");
1302
+ for (let i = 0; i < lines.length; i++) {
1303
+ if (i > 0) {
1304
+ input.dispatchEvent(new InputEvent("beforeinput", {
1305
+ inputType: "insertParagraph", bubbles: true, cancelable: true
1306
+ }));
1307
+ }
1308
+ if (lines[i]) {
1309
+ input.dispatchEvent(new InputEvent("beforeinput", {
1310
+ inputType: "insertText", data: lines[i], bubbles: true, cancelable: true
1311
+ }));
1312
+ }
1313
+ }
1314
+ } catch (err) { /* 编辑区失焦等场景静默放弃 */ }
1315
+ }, 0);
1316
+ return true;
1317
+ }
1318
+
1319
+ /** 工具栏格式动作执行(value/选区基于「投影文本」)。 */
1320
+ function applyWrapAction(card, input, kind) {
1321
+ const anchors = buildAnchors(input);
1322
+ const src = anchorsText(anchors);
1323
+ const anchor = domOffsetOf(input, anchors, true);
1324
+ if (anchor == null) return;
1325
+ const focus = domOffsetOf(input, anchors, false);
1326
+ const start = focus == null ? anchor : Math.min(anchor, focus);
1327
+ const end = focus == null ? anchor : Math.max(anchor, focus);
1328
+ const act = decideWrapAction(src, start, end, kind);
1329
+ if (!act) return;
1330
+ input.focus();
1331
+ if (act.at != null) {
1332
+ // list:光标行行首插入(单行)
1333
+ setSelectionByOffset(input, act.at, act.at);
1334
+ insertInline(input, act.insert);
1335
+ } else if (act.insert.indexOf("\n") === -1) {
1336
+ // 行内格式(bold/italic/code/link):替换选区(单行)
1337
+ setSelectionByOffset(input, start, end);
1338
+ insertInline(input, act.insert);
1339
+ requestAnimationFrame(() => setSelectionByOffset(input, act.cursorStart, act.cursorEnd == null ? act.cursorStart : act.cursorEnd));
1340
+ } else {
1341
+ // 代码块(多行):beforeinput 逐行序列
1342
+ insertInline(input, act.insert);
1343
+ }
1344
+ }
1345
+ //#endregion
1346
+
1347
+ //#region dsh-composer-live: 候选菜单定位修正(官方 bug 兜底)
1348
+ /**
1349
+ * 官方 input-trigger 候选菜单([data-trigger-menu],@ 引用与 / 命令共用)向上
1350
+ * 展开时锚定「输入组件容器顶」——输入框带图片附件(附件栏占编辑器上方
1351
+ * 66px+)时锚点不随之下移,菜单悬空 84px、整体浮出输入卡 300px+ 盖在
1352
+ * 消息流上(2026-09-25 三组对照实测:无图 gap=12px 正常 / 有图 gap=84px /
1353
+ * 抵消插件 padding-top 不变 → 官方自身定位问题,非本插件引入)。
1354
+ *
1355
+ * 兜底:菜单底边与编辑器顶的间隙 > 30px 时,用 transform:translateY 平移
1356
+ * 到贴住编辑器上沿(同无附件形态,gap≈12px)。**必须用 translateY**:
1357
+ * 实测官方菜单是 absolute + bottom:4px 锚定——bottom 锚定时 margin-top
1358
+ * 零位移(首版 marginTop 方案实测不动,且 gap 不收敛导致叠加正反馈失控
1359
+ * 累积到 7704px)、top 会与 bottom 冲突拉伸高度;官方无 transform(无弹出
1360
+ * 动画冲突),getBoundingClientRect 含 transform——每轮重测实测 rect 天然
1361
+ * 幂等收敛(gap 修到 12 即停),官方重开菜单是新元素修正不残留。只管与
1362
+ * 输入卡横向重叠且向上展开(底边在编辑器上方)的菜单,向下展开与其他
1363
+ * 组件弹层不碰;600px 上限防不可控场景的无限叠加。
1364
+ */
1365
+ function fixTriggerMenuPos(card) {
1366
+ const input = card.querySelector(INPUT_SEL);
1367
+ if (!input) return;
1368
+ const inputTop = input.getBoundingClientRect().top;
1369
+ const cardRect = card.getBoundingClientRect();
1370
+ for (const menu of document.querySelectorAll("[data-trigger-menu]")) {
1371
+ if (!menu.isConnected) continue;
1372
+ const mr = menu.getBoundingClientRect();
1373
+ if (mr.width === 0 || mr.height === 0) continue;
1374
+ if (mr.right < cardRect.left || mr.left > cardRect.right) continue; // 与输入卡无关的弹层
1375
+ const gap = inputTop - mr.bottom;
1376
+ if (gap <= 30) continue; // 12px 正常贴边;向下展开 gap 为负
1377
+ const applied = parseFloat(menu.dataset.dshClTy) || 0;
1378
+ const total = applied + (gap - 12);
1379
+ if (total > 600) continue; // 防御:修不动(官方定位法变化)就别叠加
1380
+ menu.style.transform = "translateY(" + total + "px)";
1381
+ menu.dataset.dshClTy = String(total);
1382
+ }
1383
+ }
1384
+ //#endregion
1385
+
1386
+ //#region dsh-composer-live: 选区浮动格式条
1387
+ function updateFloat(card, floatBar) {
1388
+ const input = card.querySelector(INPUT_SEL);
1389
+ if (!input || input.getAttribute("contenteditable") !== "true") { floatBar.classList.remove("dsh-cl-float-on"); return; }
1390
+ if (menuOpen(card)) { floatBar.classList.remove("dsh-cl-float-on"); return; }
1391
+ const sel = window.getSelection();
1392
+ if (!sel || sel.rangeCount === 0 || sel.isCollapsed || !sel.anchorNode || !input.contains(sel.anchorNode)) {
1393
+ floatBar.classList.remove("dsh-cl-float-on");
1394
+ return;
1395
+ }
1396
+ const text = String(sel);
1397
+ if (!text.trim()) { floatBar.classList.remove("dsh-cl-float-on"); return; }
1398
+ const rect = sel.getRangeAt(0).getBoundingClientRect();
1399
+ if (!rect || (rect.width === 0 && rect.height === 0)) { floatBar.classList.remove("dsh-cl-float-on"); return; }
1400
+ floatBar.style.left = Math.round(rect.left + rect.width / 2 - 70) + "px";
1401
+ floatBar.style.top = Math.round(rect.top - 40) + "px";
1402
+ floatBar.classList.add("dsh-cl-float-on");
1403
+ }
1404
+ //#endregion
1405
+
1406
+ //#region dsh-composer-live: Esc 分发
1407
+ function handleEscape(e, card) {
1408
+ if (menuOpen(card)) return; // 候选/弹层开着 → 归官方
1409
+ const scroll = card.querySelector(SCROLL_SEL);
1410
+ if (scroll && scroll.classList.contains(EXPANDED)) {
1411
+ const btn = card.querySelector('[data-cl="expand"]');
1412
+ if (btn) btn.click();
1413
+ e.preventDefault();
1414
+ e.stopPropagation();
1415
+ return;
1416
+ }
1417
+ const stop = findButtonByHint(card, STOP_BTN_HINTS);
1418
+ if (stop) {
1419
+ e.preventDefault();
1420
+ e.stopPropagation();
1421
+ stop.click();
1422
+ }
1423
+ }
1424
+ //#endregion
1425
+
1426
+ //#region dsh-composer-live: DOM wiring
1427
+ function setupCard(card) {
1428
+ if (!card) return;
1429
+ const input = card.querySelector(INPUT_SEL);
1430
+ if (!input) return;
1431
+
1432
+ if (card.getAttribute(READY_KEY) !== "1" && !card.querySelector("." + TOOLBAR)) {
1433
+ card.setAttribute(READY_KEY, "1");
1434
+ const scroll = card.querySelector(SCROLL_SEL);
1435
+ if (scroll) scroll.classList.add("dsh-cl-anchor");
1436
+
1437
+ // 常驻格式工具栏(6 格式键 + 展开键)
1438
+ const toolbar = document.createElement("div");
1439
+ toolbar.className = TOOLBAR;
1440
+ toolbar.setAttribute("role", "toolbar");
1441
+ toolbar.setAttribute("aria-label", "格式工具栏");
1442
+ const fmtBtn = (kind, icon, label) => {
1443
+ const b = document.createElement("button");
1444
+ b.type = "button";
1445
+ b.className = "dsh-cl-btn";
1446
+ b.dataset.cl = kind;
1447
+ b.title = label;
1448
+ b.setAttribute("aria-label", label);
1449
+ b.innerHTML = iconSvg(icon);
1450
+ b.addEventListener("mousedown", (ev) => ev.preventDefault());
1451
+ b.addEventListener("click", () => {
1452
+ const c = findCard();
1453
+ const i = c && c.querySelector(INPUT_SEL);
1454
+ if (!i) return;
1455
+ applyWrapAction(c, i, kind);
1456
+ render(c);
1457
+ });
1458
+ card.__dshClBtns = card.__dshClBtns || {};
1459
+ card.__dshClBtns[kind] = b;
1460
+ return b;
1461
+ };
1462
+ toolbar.appendChild(fmtBtn("bold", "bold", "粗体 **文字**"));
1463
+ toolbar.appendChild(fmtBtn("italic", "italic", "斜体 *文字*"));
1464
+ toolbar.appendChild(fmtBtn("code", "code", "行内代码 `代码`"));
1465
+ toolbar.appendChild(fmtBtn("link", "link", "链接 [文字](地址)"));
1466
+ toolbar.appendChild(fmtBtn("list", "list", "列表项 - "));
1467
+ toolbar.appendChild(fmtBtn("block", "block", "代码块 ```"));
1468
+ const expandBtn = document.createElement("button");
1469
+ expandBtn.type = "button";
1470
+ expandBtn.className = "dsh-cl-btn";
1471
+ expandBtn.dataset.cl = "expand";
1472
+ expandBtn.title = "展开 / 收起(70vh 写作区)";
1473
+ expandBtn.setAttribute("aria-label", "展开或收起写作区");
1474
+ expandBtn.innerHTML = iconSvg("expand");
1475
+ expandBtn.addEventListener("mousedown", (ev) => ev.preventDefault());
1476
+ expandBtn.addEventListener("click", () => {
1477
+ const s = card.querySelector(SCROLL_SEL);
1478
+ if (!s) return;
1479
+ const on = s.classList.toggle(EXPANDED);
1480
+ expandBtn.classList.toggle("dsh-cl-active", on);
1481
+ expandBtn.title = on ? "收起(恢复自动高度)" : "展开(70vh 写作区)";
1482
+ expandBtn.innerHTML = iconSvg(on ? "collapse" : "expand");
1483
+ });
1484
+ toolbar.appendChild(expandBtn);
1485
+ card.__dshClToolbar = toolbar; // 只创建不挂载——sticky 需挂进滚动容器,见下方 grow 块
1486
+ // 挂载延后到 grow(sticky 需在滚动容器内,与 overlay 同一安全位置)
1487
+
1488
+ // 选区浮动格式条(body 级 fixed)
1489
+ const floatBar = document.createElement("div");
1490
+ floatBar.className = FLOAT;
1491
+ floatBar.setAttribute("role", "toolbar");
1492
+ floatBar.setAttribute("aria-label", "选区格式");
1493
+ const mkFloatBtn = (kind, icon, label) => {
1494
+ const b = document.createElement("button");
1495
+ b.type = "button";
1496
+ b.className = "dsh-cl-btn";
1497
+ b.title = label;
1498
+ b.setAttribute("aria-label", label);
1499
+ b.innerHTML = iconSvg(icon);
1500
+ b.addEventListener("mousedown", (ev) => ev.preventDefault());
1501
+ b.addEventListener("click", () => {
1502
+ const c = findCard();
1503
+ const i = c && c.querySelector(INPUT_SEL);
1504
+ if (!i) return;
1505
+ applyWrapAction(c, i, kind);
1506
+ render(c);
1507
+ });
1508
+ return b;
1509
+ };
1510
+ floatBar.appendChild(mkFloatBtn("bold", "bold", "粗体"));
1511
+ floatBar.appendChild(mkFloatBtn("italic", "italic", "斜体"));
1512
+ floatBar.appendChild(mkFloatBtn("code", "code", "行内代码"));
1513
+ floatBar.appendChild(mkFloatBtn("link", "link", "链接"));
1514
+ document.body.appendChild(floatBar);
1515
+ card.__dshClFloat = floatBar;
1516
+
1517
+ // 事件监听(挂 card capture,React/Lexical 替换内部节点后依然有效)
1518
+ if (card.__dshClDisposables) {
1519
+ for (const d of card.__dshClDisposables) { try { d(); } catch (err) { /* 已失效 */ } }
1520
+ }
1521
+ const disposables = card.__dshClDisposables = [];
1522
+ const on = (type, fn, opts) => {
1523
+ card.addEventListener(type, fn, opts);
1524
+ disposables.push(() => card.removeEventListener(type, fn, opts));
1525
+ };
1526
+
1527
+ let rafId = 0;
1528
+ const lite = () => {
1529
+ const c = findCard();
1530
+ if (!c) return;
1531
+ render(c);
1532
+ };
1533
+ const liteRaf = () => {
1534
+ if (rafId) return;
1535
+ rafId = requestAnimationFrame(() => { rafId = 0; lite(); });
1536
+ };
1537
+
1538
+ on("input", liteRaf, true);
1539
+ on("compositionstart", () => { card.__dshClComposing = true; }, true);
1540
+ on("compositionend", () => { card.__dshClComposing = false; }, true);
1541
+ on("scroll", liteRaf, { capture: true, passive: true });
1542
+
1543
+ // 粘贴:大段代码自动包围栏(其余放行官方管线——含官方原生粘贴图片)
1544
+ on("paste", (e) => {
1545
+ if (e.__dshClSynthetic) return; // 自己派发的合成 paste 放行
1546
+ if (card.__dshClComposing) return;
1547
+ const input2 = card.querySelector(INPUT_SEL);
1548
+ if (!input2 || input2.getAttribute("contenteditable") !== "true") return;
1549
+ const phase = input2.getAttribute("data-phase");
1550
+ if (phase && phase !== "plain") return;
1551
+ if (menuOpen(card)) return;
1552
+ if (!e.clipboardData) return;
1553
+ const text = e.clipboardData.getData("text/plain") || "";
1554
+ if (!text) return; // 图片/文件粘贴归官方 intakeFiles
1555
+ const anchors = buildAnchors(input2);
1556
+ const src = anchorsText(anchors);
1557
+ const caret = domOffsetOf(input2, anchors, true);
1558
+ if (caret == null) return;
1559
+ const wrap = decidePasteWrap(src, caret, text);
1560
+ if (!wrap) return;
1561
+ e.preventDefault();
1562
+ e.stopPropagation();
1563
+ insertInline(input2, wrap.insert);
1564
+ toast(card, "已将 " + wrap.lines + " 行文本包成代码块(Ctrl+Z 撤销)");
1565
+ liteRaf();
1566
+ }, true);
1567
+
1568
+ // 键盘:Enter 围栏补全 / Tab 块内缩进 / Esc 分发
1569
+ on("keydown", (e) => {
1570
+ if (e.isComposing || e.keyCode === 229 || card.__dshClComposing) return;
1571
+ const input2 = e.target && e.target.closest ? e.target.closest(INPUT_SEL) : null;
1572
+ if (!input2 || input2.getAttribute("contenteditable") !== "true") return;
1573
+ if (e.key === "Escape") { handleEscape(e, card); return; }
1574
+ const phase = input2.getAttribute("data-phase");
1575
+ if (phase && phase !== "plain") return;
1576
+ if (menuOpen(card)) return; // 候选菜单开着 → 全归官方
1577
+ const anchors = buildAnchors(input2);
1578
+ const src = anchorsText(anchors);
1579
+ const caret = domOffsetOf(input2, anchors, true);
1580
+ if (caret == null) return;
1581
+ const sel = window.getSelection();
1582
+ // Ctrl+B/I/E 格式快捷键(选区态包裹/光标态插空标记——applyWrapAction
1583
+ // 内部分派,与工具栏按钮同通道)。拦截同时挡掉浏览器对 contenteditable
1584
+ // 的默认 execCommand('bold')——那会插真 <b> 富文本标签进编辑器。
1585
+ if (e.ctrlKey && !e.altKey && !e.metaKey && !e.shiftKey
1586
+ && (e.key === "b" || e.key === "B" || e.key === "i" || e.key === "I" || e.key === "e" || e.key === "E")) {
1587
+ e.preventDefault();
1588
+ e.stopPropagation();
1589
+ applyWrapAction(card, input2, e.key.toLowerCase() === "b" ? "bold" : (e.key.toLowerCase() === "i" ? "italic" : "code"));
1590
+ liteRaf();
1591
+ return;
1592
+ }
1593
+ // 选区跨行 Tab/Shift+Tab:整块每行 ±2 空格(缩进完选区恢复为新块,
1594
+ // 连按 Tab 可逐级缩进)
1595
+ if (e.key === "Tab" && !e.ctrlKey && !e.altKey && !e.metaKey && sel && !sel.isCollapsed) {
1596
+ const focus = domOffsetOf(input2, anchors, false);
1597
+ if (focus != null) {
1598
+ const bact = decideTabBlock(src, caret, focus, e.shiftKey);
1599
+ e.preventDefault();
1600
+ e.stopPropagation();
1601
+ if (bact.type === "block-indent") {
1602
+ // 整块替换走「先删后插」三步链:直接在有选区上跑 insertInline 的
1603
+ // beforeinput 序列会与 Lexical 内部 selection 状态不同步(E2E 实测
1604
+ // 首段丢失)——先合成删除选区(list-exit 同款通道),commit 后再
1605
+ // 插入新块,最后恢复选区。
1606
+ setSelectionByOffset(input2, bact.rangeStart, bact.rangeEnd);
1607
+ setTimeout(() => {
1608
+ try {
1609
+ input2.dispatchEvent(new InputEvent("beforeinput", { inputType: "deleteContentBackward", bubbles: true, cancelable: true }));
1610
+ } catch (err) { /* 放弃 */ }
1611
+ setTimeout(() => {
1612
+ insertInline(input2, bact.insert);
1613
+ setTimeout(() => { setSelectionByOffset(input2, bact.selStart, bact.selEnd); }, 120);
1614
+ }, 80);
1615
+ }, 0);
1616
+ } else if (bact.type !== "tab-noop") {
1617
+ insertInline(input2, bact.insert); // 退化:选区替换为两空格(行内 Tab 语义)
1618
+ }
1619
+ liteRaf();
1620
+ return;
1621
+ }
1622
+ }
1623
+ if (sel && !sel.isCollapsed) return;
1624
+ const act = decideKeyAction(src, caret, e.key, {
1625
+ shift: e.shiftKey, ctrl: e.ctrlKey, alt: e.altKey, meta: e.metaKey
1626
+ });
1627
+ if (!act) return;
1628
+ e.preventDefault();
1629
+ e.stopPropagation();
1630
+ if (act.type === "indent") {
1631
+ insertInline(input2, act.insert);
1632
+ } else if (act.type === "indent-at") {
1633
+ // 行首缩进(嵌套列表/引用):光标移行首 → 插两空格 → 延时光标跟到原位+2
1634
+ setSelectionByOffset(input2, act.at, act.at);
1635
+ insertInline(input2, " ");
1636
+ setTimeout(() => {
1637
+ setSelectionByOffset(input2, act.cursor, act.cursor);
1638
+ }, 80);
1639
+ } else if (act.type === "outdent") {
1640
+ // Shift+Tab 反缩进:选区删除(与 list-exit 同款合成删除通道)
1641
+ setSelectionByOffset(input2, act.rangeStart, act.rangeEnd);
1642
+ setTimeout(() => {
1643
+ try {
1644
+ input2.dispatchEvent(new InputEvent("beforeinput", { inputType: "deleteContentBackward", bubbles: true, cancelable: true }));
1645
+ } catch (err) { /* 编辑器不可编辑时放弃 */ }
1646
+ }, 0);
1647
+ } else if (act.type === "tab-noop") {
1648
+ // 无缩进可删的 Shift+Tab:仅吞掉按键防焦点跳出(preventDefault 已统一执行)
1649
+ } else if (act.type === "fence") {
1650
+ insertInline(input2, act.insert);
1651
+ // 光标落到块内空行:插入串 "\n\n```..." 的第一个 \n 之后。
1652
+ // Lexical 对 beforeinput 的 commit 是异步的(实测 ~几十 ms),
1653
+ // 光标设置必须等 commit 完成后再做,否则被 Lexical 重置回插入点。
1654
+ setTimeout(() => {
1655
+ setSelectionByOffset(input2, caret + 1, caret + 1);
1656
+ }, 80);
1657
+ } else if (act.type === "table-sep") {
1658
+ // 表格首行补分隔行+空行:插入后光标落末尾空行(Lexical 异步 commit 后设置)
1659
+ insertInline(input2, act.insert);
1660
+ setTimeout(() => {
1661
+ setSelectionByOffset(input2, act.cursor, act.cursor);
1662
+ }, 80);
1663
+ } else if (act.type === "list") {
1664
+ // 列表续项:插入 "\n标记 ",光标落标记后(Lexical 异步 commit 后设置)
1665
+ insertInline(input2, act.insert);
1666
+ setTimeout(() => {
1667
+ setSelectionByOffset(input2, act.cursor, act.cursor);
1668
+ }, 80);
1669
+ } else if (act.type === "list-exit") {
1670
+ // 空列表项退出:选中「行首到标记尾」后走合成 beforeinput 删除
1671
+ // (execCommand("delete") 需要 user activation——无手势时静默 no-op,
1672
+ // CDP/特殊环境下不可靠;Lexical 对 beforeinput 的处理不校验激活)
1673
+ setSelectionByOffset(input2, act.rangeStart, act.rangeEnd);
1674
+ setTimeout(() => {
1675
+ try {
1676
+ input2.dispatchEvent(new InputEvent("beforeinput", { inputType: "deleteContentBackward", bubbles: true, cancelable: true }));
1677
+ } catch (err) { /* 编辑器不可编辑时放弃 */ }
1678
+ }, 0);
1679
+ } else if (act.type === "jumpout") {
1680
+ // 块内最后行 ↓ 跳出:纯光标移动(无插入),同步设置即可
1681
+ setSelectionByOffset(input2, act.cursor, act.cursor);
1682
+ }
1683
+ liteRaf();
1684
+ }, true);
1685
+ }
1686
+
1687
+ // 确保 overlay 与哨兵在位(插在编辑层之前,画在其下不挡光标)
1688
+ const grow = input.parentElement;
1689
+ if (grow) {
1690
+ let ov = grow.querySelector("." + LIVE);
1691
+ if (!ov) {
1692
+ ov = document.createElement("div");
1693
+ ov.className = LIVE;
1694
+ ov.setAttribute("aria-hidden", "true");
1695
+ grow.insertBefore(ov, input);
1696
+ }
1697
+ // sticky 工具栏进滚动容器(在 overlay 前):未挂载或挂错位置都搬正
1698
+ const tb = card.__dshClToolbar || card.querySelector("." + TOOLBAR);
1699
+ if (tb && (tb.parentElement !== grow || tb.nextElementSibling !== ov)) grow.insertBefore(tb, ov);
1700
+ if (!card.__dshClSentinel || !card.__dshClSentinel.isConnected) {
1701
+ const s = document.createElement("div");
1702
+ s.className = SENTINEL;
1703
+ s.setAttribute("aria-hidden", "true");
1704
+ grow.appendChild(s);
1705
+ card.__dshClSentinel = s;
1706
+ }
1707
+ }
1708
+ render(card);
1709
+ }
1710
+ //#endregion
1711
+
1712
+ //#region dsh-composer-live: apply
1713
+ function apply(ctx) {
1714
+ ctx.effect(() => {
1715
+ let styleEl = document.getElementById(STYLE_ID);
1716
+ if (!styleEl) {
1717
+ styleEl = document.createElement("style");
1718
+ styleEl.id = STYLE_ID;
1719
+ styleEl.textContent = styleText();
1720
+ document.head.appendChild(styleEl);
1721
+ }
1722
+
1723
+ const run = () => { setupCard(findCard()); };
1724
+ run();
1725
+
1726
+ // 只读调试钩子(E2E 测试交叉验证用):暴露投影文本/行数/锚构成/渲染
1727
+ // 段数/透明化状态。纯读取无写入面,不违反零服务依赖原则;cleanup 移除。
1728
+ window.__dshClDebug = () => {
1729
+ const card = findCard();
1730
+ const input = card ? card.querySelector(INPUT_SEL) : null;
1731
+ if (!input) return { found: false };
1732
+ const anchors = buildAnchors(input);
1733
+ const src = anchorsText(anchors);
1734
+ const ov = card.querySelector("." + LIVE);
1735
+ const segs = ov ? ov.querySelectorAll(".dsh-cl-c").length : -1;
1736
+ return {
1737
+ found: true,
1738
+ src,
1739
+ lineCount: String(src || "").split("\n").length,
1740
+ anchorKinds: anchors.reduce((m, a) => { m[a.kind] = (m[a.kind] || 0) + 1; return m; }, {}),
1741
+ segs,
1742
+ host: input.classList.contains(HOST),
1743
+ editable: input.getAttribute("contenteditable") === "true"
1744
+ };
1745
+ };
1746
+
1747
+ // MutationObserver 回调 rAF 合帧:每次击键 Lexical 的 DOM commit 会
1748
+ // 触发多次 observer 回调(文本+结构),同步跑 render 会造成每键多次
1749
+ // 渲染 + 强制布局(「抖动」感来源之一)——合并到下一帧只跑一次。
1750
+ let obsRaf = 0;
1751
+ const observer = new MutationObserver(() => {
1752
+ if (obsRaf) return;
1753
+ obsRaf = requestAnimationFrame(() => { obsRaf = 0; run(); });
1754
+ });
1755
+ observer.observe(document.body, { childList: true, subtree: true, characterData: true });
1756
+
1757
+ // 窗口尺寸变化:字符锚定渲染的坐标随布局重算(DOM 无变化不触发 MO)
1758
+ let rsRaf = 0;
1759
+ const onResize = () => {
1760
+ if (rsRaf) return;
1761
+ rsRaf = requestAnimationFrame(() => { rsRaf = 0; run(); });
1762
+ };
1763
+ window.addEventListener("resize", onResize);
1764
+
1765
+ const onSelChange = () => {
1766
+ const card = findCard();
1767
+ if (!card) return;
1768
+ const input = card.querySelector(INPUT_SEL);
1769
+ if (!input || document.activeElement !== input) {
1770
+ if (card.__dshClFloat) card.__dshClFloat.classList.remove("dsh-cl-float-on");
1771
+ return;
1772
+ }
1773
+ render(card);
1774
+ if (card.__dshClFloat) updateFloat(card, card.__dshClFloat);
1775
+ };
1776
+ document.addEventListener("selectionchange", onSelChange);
1777
+
1778
+ const onDocDown = (e) => {
1779
+ const card = findCard();
1780
+ if (!card || !card.__dshClFloat) return;
1781
+ if (card.__dshClFloat.contains(e.target)) return;
1782
+ if (e.target.closest && (e.target.closest(CARD_SEL) || e.target.closest(MENU_SEL))) return;
1783
+ card.__dshClFloat.classList.remove("dsh-cl-float-on");
1784
+ };
1785
+ document.addEventListener("pointerdown", onDocDown, true);
1786
+
1787
+ const timer = setInterval(run, 500);
1788
+
1789
+ return () => {
1790
+ delete window.__dshClDebug;
1791
+ clearInterval(timer);
1792
+ observer.disconnect();
1793
+ window.removeEventListener("resize", onResize);
1794
+ document.removeEventListener("selectionchange", onSelChange);
1795
+ document.removeEventListener("pointerdown", onDocDown, true);
1796
+ document.body.classList.remove("dsh-cl-light");
1797
+ const card = findCard();
1798
+ if (card) {
1799
+ if (card.__dshClDisposables) {
1800
+ for (const d of card.__dshClDisposables) { try { d(); } catch (err) { /* 已失效 */ } }
1801
+ card.__dshClDisposables = null;
1802
+ }
1803
+ if (card.__dshClFloat) { card.__dshClFloat.remove(); card.__dshClFloat = null; }
1804
+ card.removeAttribute(READY_KEY);
1805
+ const tb = card.querySelector("." + TOOLBAR);
1806
+ if (tb) tb.remove();
1807
+ const input = card.querySelector(INPUT_SEL);
1808
+ if (input) {
1809
+ input.classList.remove(HOST, MONO_ALL);
1810
+ input.querySelectorAll("p").forEach((p) => p.classList.remove(KEEP));
1811
+ }
1812
+ const ov = card.querySelector("." + LIVE);
1813
+ if (ov) ov.remove();
1814
+ const ts = card.querySelector("." + TOAST);
1815
+ if (ts) ts.remove();
1816
+ if (card.__dshClSentinel) { card.__dshClSentinel.remove(); card.__dshClSentinel = null; }
1817
+ const scroll = card.querySelector(SCROLL_SEL);
1818
+ if (scroll) scroll.classList.remove(EXPANDED, "dsh-cl-anchor", "dsh-cl-light");
1819
+ card.style.removeProperty("--dsh-cl-code-font");
1820
+ }
1821
+ const s = document.getElementById(STYLE_ID);
1822
+ if (s) s.remove();
1823
+ };
1824
+ });
1825
+ }
1826
+ //#endregion
1827
+
1828
+ exports.apply = apply;
1829
+ exports.inject = [];
1830
+ /** 测试/调试用纯函数导出(生产无副作用)。 */
1831
+ exports._renderInline = renderInline;
1832
+ exports._computeBlockStates = computeBlockStates;
1833
+ exports._lineClass = lineClass;
1834
+ exports._lineContent = lineContent;
1835
+ exports._blockKindOf = blockKindOf;
1836
+ exports._renderBlockLine = renderBlockLine;
1837
+ exports._stateKey = stateKey;
1838
+ exports._isLightColor = isLightColor;
1839
+ exports._decideActiveMarks = decideActiveMarks;
1840
+ exports._decideKeyAction = decideKeyAction;
1841
+ exports._decideTabBlock = decideTabBlock;
1842
+ exports._highlightLine = highlightLine;
1843
+ exports._guessLang = guessLang;
1844
+ exports._buildCodeFont = buildCodeFont;
1845
+ exports._decideWrapAction = decideWrapAction;
1846
+ exports._isCursorInCodeBlock = isCursorInCodeBlock;
1847
+ exports._decidePasteWrap = decidePasteWrap;
1848
+ exports._codeParaFlags = codeParaFlags;
1849
+ exports._paraHasChip = paraHasChip;
1850
+ return module.exports;
1851
+ }
1852
+ });