dsh-happy-reader 0.3.0 → 0.3.2

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.
Files changed (3) hide show
  1. package/README.md +23 -3
  2. package/lib/client.js +419 -69
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -16,14 +16,17 @@
16
16
  组件挂载、不直接改写页面结构;取色只用官方主题变量(自动跟随明暗与品牌色);文案走
17
17
  官方语言服务(随界面语言切换中/英);开关采用官方无障碍规范(`role="switch"`,
18
18
  键盘与读屏可用);正文字体的中/西两行也注册进官方「通用设置」页(`settings.general.item`),
19
- 两处入口共用同一份设置;不修改任何官方文件。与官方叠加层共存:右键菜单、提示条
19
+ 两处入口共用同一份设置;不修改任何官方文件。浅色主题下为白底深字,深色主题跟随
20
+ 官方菜单色;保留毛玻璃观感,同时打开路径不再做多余的自然高度测量、
21
+ 观察器接线也不抢首帧。与官方叠加层共存:右键菜单、提示条
20
22
  始终显示在插件之上;Esc 在官方对话框/菜单里让位,在插件自己的输入框里只取消输入。
21
23
 
22
24
  ## 功能详解
23
25
 
24
26
  ### 1. 隐藏输入区
25
27
  一键收起底部的输入框,把屏幕还给内容。
26
- - 收起后官方聊天的滚动与阅读位置不受影响(是收起高度,不是粗暴隐藏)
28
+ - 收起后官方聊天的滚动与阅读位置不受影响(保留座位几何,是收起而非 display:none)
29
+ - 隐藏态输入区不可聚焦:官方“引用文本”仍会写入草稿,但不会再带动会话滚动
27
30
  - 随时再点一下开关即恢复;状态自动记住
28
31
 
29
32
  ### 2. 隐藏上边栏
@@ -93,9 +96,13 @@
93
96
  (直接粘贴到论文、笔记里就是可编辑的公式源码,不会再是一团乱码)
94
97
  - **含公式的复制会同时写入富文本格式**(与 Cherry Studio 的 copy-tex 同策略):
95
98
  粘贴到 Word、WPS 等支持富文本的地方,公式仍以可显示的样子出现
96
- - **代码块**转为 \`\`\` 围栏、行内代码保留反引号
99
+ - **代码块**转为 \`\`\` 围栏,并保留 DSH 代码横幅里的语言(如 \`\`\`python);
100
+ 横幅与复制按钮不会混进正文;行内代码保留反引号
97
101
  - **加粗/斜体/删除线/链接/图片/标题/列表/引用**按 Markdown 语法保留;
98
102
  **表格**转为 GFM 表格(`| 列 |` + 分隔行)、**任务列表**转为 `- [x] / - [ ]`
103
+ - **引用/标题**内选区补回 `>` / `#`;**表格**选区自动补整行、原表头和分隔行;
104
+ 单格/单项内部按格内/项内内容复制。DSH 聊天只渲染 GFM 表(raw HTML 按文本显示);
105
+ 跨格/整表选择时,合并单元格/无表头等非 GFM 结构整段交还原生复制,不伪造表格
99
106
  - 只在消息区域生效;复制界面其它地方(如设置、输入框)保持系统默认行为
100
107
  - **与官方「引用文本」不冲突(v0.3.0 核对)**:官方引用是选中文字后出现的按钮,
101
108
  把纯文本插入输入框;本功能只接管消息区内的 `copy` 事件(Ctrl+C)改写剪贴板,
@@ -114,6 +121,19 @@
114
121
  - 位置与高度自动记住;只有真正拖动过才会记住(点一下不受影响);
115
122
  **双击标题栏**一键复位回自动布局
116
123
 
124
+ ### 10. 快捷键(官方通用设置)
125
+ - 通过官方 `ctx.shortcuts` 注册两条命令,自动出现在**设置 → 通用 → 快捷键**
126
+ (官方“编辑快捷键”入口)里:**隐藏输入框**、**隐藏上边栏**
127
+ - 桌面端默认键位:Windows/Linux `Ctrl+Shift+I` / `Ctrl+Shift+T`,
128
+ macOS `⌘+Shift+I` / `⌘+Shift+T`;Web 端默认不绑定
129
+ - 在速查里可以直接录制、清除或恢复默认;如果默认键位被注册服务拒绝(与已注册
130
+ 命令冲突),插件会自动改为“未绑定”命令;若被固定操作占用,速查会标出冲突,
131
+ 仍可自行改键
132
+ - 面板里的“隐藏输入框 / 隐藏上边栏”行会在悬停或键盘聚焦时显示圆角整行高亮,
133
+ 并在标题行右侧显示与官方新会话按钮同款的键帽提示;键位随官方设置实时刷新,
134
+ 清除绑定后自动隐藏
135
+ - 命令随插件停用/卸载移除;弹窗打开时快捷键让位,不会误触发
136
+
117
137
  > 推荐组合:**隐藏输入框 + 隐藏上边栏 + 全屏** —— 一键进入沉浸阅读模式。
118
138
 
119
139
  ## 安装
package/lib/client.js CHANGED
@@ -6,7 +6,8 @@
6
6
  // 面板拖动/拉高、复制为 Markdown。
7
7
  //
8
8
  // 关键约束:
9
- // · 隐藏输入区用 height:0(官方滚动锚定依赖几何),不用 display:none
9
+ // · 隐藏输入区用 height:0(官方滚动锚定依赖几何)+ visibility:hidden(阻止聚焦),
10
+ // 不用 display:none
10
11
  // · <style>.disabled 必须挂载后再设(未连接时设置会被 CSSOM 静默丢弃)
11
12
  // · 文本 UI 只走官方 shell.overlay / settings.general.item 槽位、locale 与主题 token
12
13
  // · 官方字号/字体走 theme.setFontSize / setFontFamily 角色接口;中英分字用
@@ -20,7 +21,7 @@ window.__ModuleLoader__.load({
20
21
  var exports = module.exports
21
22
 
22
23
  // ── 常量 ────────────────────────────────────────────────────────────
23
- const VERSION = '0.3.0'
24
+ const VERSION = '0.3.2'
24
25
  const INSTANCE_TOKEN = {}
25
26
  const GROUPS_VERSION = 3
26
27
  const STATE_KEY = 'dsh.reader-boost.v1'
@@ -107,11 +108,19 @@ window.__ModuleLoader__.load({
107
108
  .dsrb-x:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(255,255,255,.16))}
108
109
  .dsrb-row{display:flex;align-items:flex-start;gap:10px;padding:8px 0;
109
110
  border-top:1px solid var(--dsw-alias-border-l1,rgba(128,128,128,.14))}
110
- .dsrb-row-click{cursor:pointer}
111
- .dsrb-row-click:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(255,255,255,.045))}
112
- .dsrb-rowMain{flex:1;min-width:0}
111
+ /* 点击行:悬停底色铺满整行(含面板内边距),圆角,避免只盖住内容区的方形小框 */
112
+ .dsrb-row-click{margin:0 -14px;padding:8px 14px;border-radius:8px;cursor:pointer;transition:background .12s,border-color .12s}
113
+ .dsrb-row-click:hover,.dsrb-row-click:focus-within{background:var(--dsw-alias-interactive-bg-hover,rgba(255,255,255,.045));border-top-color:transparent}
114
+ .dsrb-rowMain{flex:1;min-width:0;position:relative}
113
115
  .dsrb-rowTitle{font-weight:600}
114
116
  .dsrb-rowTitle b{color:var(--dsw-alias-label-primary,#fff);font-weight:600}
117
+ /* 快捷键提示叠在标题行右侧:不占布局宽度,副文案不会再被挤成省略号 */
118
+ .dsrb-keys{position:absolute;top:1px;right:0;display:inline-flex;align-items:center;gap:2px;max-width:60%;color:var(--dsw-alias-label-tertiary,#8d8d8d);
119
+ font-size:12px;line-height:16px;white-space:nowrap;overflow:hidden;opacity:0;pointer-events:none;transition:opacity .12s}
120
+ .dsrb-key{display:inline-flex;align-items:center;justify-content:center;padding:0;border:0;background:transparent;font:inherit}
121
+ .dsrb-keySeparator{font:inherit;color:var(--dsw-alias-label-caption,var(--dsw-alias-label-tertiary,#8d8d8d))}
122
+ .dsrb-row-click:hover .dsrb-keys,.dsrb-row-click:focus-within .dsrb-keys{opacity:1}
123
+ @media (hover:none){.dsrb-row-click .dsrb-keys{opacity:1}}
115
124
  .dsrb-rowSub{color:var(--dsw-alias-label-secondary,#9a9a9a);font-size:12px;margin-top:2px}
116
125
  /* 开关:对齐官方 role=switch 视觉(36×20、2px 内边距、16px 滑块、16px 行程) */
117
126
  .dsrb-switch{position:relative;flex:none;width:36px;height:20px;padding:2px;box-sizing:border-box;
@@ -183,7 +192,9 @@ window.__ModuleLoader__.load({
183
192
  .dsrb-set-select option{background:var(--dsw-alias-bg-layer-3,#1c1c1e);color:var(--dsw-alias-label-primary,#eee)}
184
193
  `
185
194
  // 隐藏输入区(关键:height:0 而非 display:none —— 官方滚动锚定依赖该元素的几何信息)
186
- const HIDE_CSS = '[data-composer-seat]{height:0!important;min-height:0!important;overflow:hidden!important}'
195
+ // visibility:hidden 让隐藏的输入区无法被聚焦——官方“引用文本”仍能写入 Lexical 草稿,
196
+ // 但浏览器不会再为了露出 0 高光标而滚动会话;比焦点后恢复滚动更干净。
197
+ const HIDE_CSS = '[data-composer-seat]{height:0!important;min-height:0!important;overflow:hidden!important;visibility:hidden!important}'
187
198
  // 隐藏上边栏:正向判据(官方 conversation.header 槽位宿主带 data-conversation-header-leading,
188
199
  // 全应用仅此一处);排除对话框内,避免误伤插件页/嵌入页的头部。
189
200
  const HIDE_TOP_CSS = 'header[data-window-drag]:has(> [data-conversation-header-leading]):not([role="dialog"] *){display:none!important}'
@@ -655,6 +666,28 @@ window.__ModuleLoader__.load({
655
666
  let settingsRefresh = null
656
667
  let settingsAlive = false
657
668
  let remounting = false // 语言切换等就地重挂:不算插件停用,不退出全屏
669
+ let shortcutActions = null // 当前实体提供的隐藏开关动作;未挂载时快捷键命令直接 pass
670
+ let shortcutHintCatalog = null // 官方快捷键目录(随用户改键发布)
671
+ let shortcutHintNodes = null // { 命令 id: { hint, input } }
672
+
673
+ /** 把官方有效键位同步到面板行内的快捷键提示。 */
674
+ function refreshShortcutHints() {
675
+ if (!shortcutHintNodes) return
676
+ const rows = shortcutHintCatalog && typeof shortcutHintCatalog.getSnapshot === 'function'
677
+ ? shortcutHintCatalog.getSnapshot() : []
678
+ for (const [id, target] of Object.entries(shortcutHintNodes)) {
679
+ const row = rows.find((item) => item.id === id)
680
+ const keys = row && Array.isArray(row.keys) ? row.keys : []
681
+ target.hint.textContent = ''
682
+ for (const key of keys) {
683
+ target.hint.append(el(key === '+' ? 'span' : 'kbd', key === '+' ? 'dsrb-keySeparator' : 'dsrb-key', key))
684
+ }
685
+ target.hint.style.display = keys.length ? '' : 'none'
686
+ // 与官方新会话按钮一致:让辅助技术直接拿到组合键
687
+ if (row && row.aria) target.input.setAttribute('aria-keyshortcuts', row.aria)
688
+ else if (typeof target.input.removeAttribute === 'function') target.input.removeAttribute('aria-keyshortcuts')
689
+ }
690
+ }
658
691
 
659
692
  /** 官方主题快照 → 字号/字体族桥接(0.2.2 角色形与旧版单值形都读)。 */
660
693
  function syncTheme(snap) {
@@ -958,6 +991,65 @@ window.__ModuleLoader__.load({
958
991
  }
959
992
  })
960
993
 
994
+ // ── 官方快捷键:命令自动出现在「通用设置 → 快捷键」速查;动作由当前实体提供 ──
995
+ ctx.inject(['shortcuts'], (sctx) => {
996
+ const shortcuts = sctx.shortcuts
997
+ if (!shortcuts || typeof shortcuts.register !== 'function') return
998
+ const desktopDefaults = (code) => ({
999
+ 'desktop:macos': { code, modifiers: ['primary', 'shift'] },
1000
+ 'desktop:windows': { code, modifiers: ['primary', 'shift'] },
1001
+ 'desktop:linux': { code, modifiers: ['primary', 'shift'] },
1002
+ })
1003
+ const command = (id, labelKey, aliases, code, action) => ({
1004
+ id,
1005
+ label: () => t(labelKey),
1006
+ aliases,
1007
+ defaults: desktopDefaults(code),
1008
+ regions: ['page', 'editable', 'terminal'],
1009
+ modals: [],
1010
+ resolve: (context) => {
1011
+ if (context && context.modal != null) return { status: 'blocked', reason: 'modal' }
1012
+ if (!shortcutActions || typeof shortcutActions[action] !== 'function') return { status: 'pass' }
1013
+ return { status: 'handled', run: () => shortcutActions[action]() }
1014
+ },
1015
+ })
1016
+ const commands = [
1017
+ command('happyReader.hideComposer', 'hideComposerTitle',
1018
+ ['hide composer', 'hide input', 'reader input'], 'KeyI', 'hideComposer'),
1019
+ command('happyReader.hideTop', 'hideTopTitle',
1020
+ ['hide title bar', 'hide top bar', 'reader top'], 'KeyT', 'hideTop'),
1021
+ ]
1022
+ for (const def of commands) {
1023
+ try {
1024
+ sctx.effect(() => {
1025
+ try {
1026
+ return shortcuts.register(def)
1027
+ } catch (err) {
1028
+ // 默认键位撞上官方/其它插件时,命令仍以未绑定形式出现在快捷键设置里
1029
+ console.warn('[dsh-happy-reader] 快捷键默认绑定不可用,改为未绑定:', def.id, err)
1030
+ try { return shortcuts.register({ ...def, defaults: {} }) } catch { return () => {} }
1031
+ }
1032
+ }, 'dsh-happy-reader: 快捷键 ' + def.id)
1033
+ } catch (err) {
1034
+ console.warn('[dsh-happy-reader] 快捷键注册失败:', def.id, err)
1035
+ }
1036
+ }
1037
+ // 面板上的快捷键提示跟随官方目录:用户改键/清除后立即刷新
1038
+ shortcutHintCatalog = shortcuts.catalog || null
1039
+ sctx.effect(() => {
1040
+ refreshShortcutHints()
1041
+ const catalog = shortcuts.catalog
1042
+ if (!catalog || typeof catalog.subscribe !== 'function') return () => {}
1043
+ try {
1044
+ const off = catalog.subscribe(() => refreshShortcutHints())
1045
+ return typeof off === 'function' ? off : () => {}
1046
+ } catch (err) {
1047
+ console.warn('[dsh-happy-reader] 快捷键提示订阅失败:', err)
1048
+ return () => {}
1049
+ }
1050
+ }, 'dsh-happy-reader: 快捷键提示')
1051
+ })
1052
+
961
1053
  // ── 渲染面:官方 shell.overlay 槽位(框架级覆盖层,pointer-events:none) ──
962
1054
  let registered = false
963
1055
  let standalone = null // 旧外壳回退时直挂 body 的宿主,卸载时一并移除
@@ -1084,7 +1176,7 @@ window.__ModuleLoader__.load({
1084
1176
  headRow.append(title, headHint, closeBtn)
1085
1177
 
1086
1178
  /** 开关行:官方 36×20 视觉 + role=switch;整条可点由 bindRowToggle 绑定。 */
1087
- function makeSwitchRow(titleText, subText) {
1179
+ function makeSwitchRow(titleText, subText, withShortcut) {
1088
1180
  const row = el('div', 'dsrb-row dsrb-row-click')
1089
1181
  const box = el('span', 'dsrb-switch') // span 而非 label:避免原生转发激活面
1090
1182
  const input = el('input')
@@ -1094,13 +1186,24 @@ window.__ModuleLoader__.load({
1094
1186
  input.setAttribute('aria-label', titleText)
1095
1187
  box.append(input, el('span'))
1096
1188
  const main = el('div', 'dsrb-rowMain')
1097
- main.append(el('div', 'dsrb-rowTitle', titleText), el('div', 'dsrb-rowSub', subText))
1189
+ const title = el('div', 'dsrb-rowTitle', titleText)
1190
+ const sub = el('div', 'dsrb-rowSub', subText)
1191
+ const hint = withShortcut ? el('span', 'dsrb-keys') : null
1192
+ if (hint) hint.setAttribute('aria-hidden', 'true')
1193
+ main.append(title, sub)
1194
+ if (hint) main.append(hint)
1098
1195
  row.append(box, main)
1099
- return { row, input }
1196
+ return { row, input, hint }
1100
1197
  }
1101
- const { row: rowHide, input: hideToggle } = makeSwitchRow(t('hideComposerTitle'), t('hideComposerSub'))
1198
+ const { row: rowHide, input: hideToggle, hint: hideComposerHint } = makeSwitchRow(t('hideComposerTitle'), t('hideComposerSub'), true)
1102
1199
 
1103
- const { row: rowTop, input: topToggle } = makeSwitchRow(t('hideTopTitle'), t('hideTopSub'))
1200
+ const { row: rowTop, input: topToggle, hint: hideTopHint } = makeSwitchRow(t('hideTopTitle'), t('hideTopSub'), true)
1201
+ const shortcutHintMap = {
1202
+ 'happyReader.hideComposer': { hint: hideComposerHint, input: hideToggle },
1203
+ 'happyReader.hideTop': { hint: hideTopHint, input: topToggle },
1204
+ }
1205
+ shortcutHintNodes = shortcutHintMap
1206
+ refreshShortcutHints()
1104
1207
 
1105
1208
  // 行:行距 / 字距(面板最下方;默认 = 官方原样)
1106
1209
  const rowType = el('div', 'dsrb-row')
@@ -1153,7 +1256,6 @@ window.__ModuleLoader__.load({
1153
1256
  group.append(head, body)
1154
1257
  head.addEventListener('click', () => toggleGroup(key))
1155
1258
  groups[key] = { group, head, body, sub }
1156
- return groups[key]
1157
1259
  }
1158
1260
  function applyGroupState() {
1159
1261
  for (const key of Object.keys(groups)) {
@@ -1207,6 +1309,7 @@ window.__ModuleLoader__.load({
1207
1309
  picker.list = list
1208
1310
  fillFontOptions(select, list)
1209
1311
  select.append(customOpt)
1312
+ currentOpt = null // 重新填充后旧的“当前”选项已被清掉
1210
1313
  picker.render()
1211
1314
  }
1212
1315
  picker.setEnabled = (on) => {
@@ -1376,17 +1479,22 @@ window.__ModuleLoader__.load({
1376
1479
  function placePanel(keepScroll) {
1377
1480
  const scrollTop = typeof keepScroll === 'number' ? keepScroll : panelBody.scrollTop
1378
1481
  const r = fab.getBoundingClientRect()
1379
- panel.style.height = ''
1380
- panel.style.maxHeight = '' // 先按自然高度测量
1482
+ const fixedH = typeof state.panelH === 'number'
1483
+ if (!fixedH) {
1484
+ panel.style.height = ''
1485
+ panel.style.maxHeight = '' // 先按自然高度测量
1486
+ }
1487
+ // 用户已锁定高度时无需清空高度再量自然高度(那会强制整面板重排);
1488
+ // 宽度仍按实测外框取(常量 280 不含左右 1px 边框)。
1381
1489
  const pw = panel.offsetWidth || PANEL_WIDTH
1382
- const natural = panel.offsetHeight || 240
1490
+ const natural = fixedH ? 0 : panel.offsetHeight || 240
1383
1491
  const GAP = 6
1384
1492
  const M = 8
1385
1493
  const vw = window.innerWidth
1386
1494
  const vh = window.innerHeight
1387
1495
  const CAP_H = document.fullscreenElement ? 0 : titlebarH
1388
1496
  // 定位按"实际将显示的高度"算 —— 底缘才能与按钮对齐,不会忽远忽近
1389
- const desired = typeof state.panelH === 'number' ? state.panelH : Math.min(natural, PANEL_MAX_H)
1497
+ const desired = fixedH ? state.panelH : Math.min(natural, PANEL_MAX_H)
1390
1498
  const ph = Math.max(PANEL_MIN_H, Math.min(desired, vh - 2 * M - BORDER_Y))
1391
1499
  const clampX = (x) => Math.min(Math.max(M, x), Math.max(M, vw - pw - M))
1392
1500
  const clampY = (y, yMin) => Math.min(Math.max(yMin, y), Math.max(yMin, vh - ph - BORDER_Y - M))
@@ -1435,10 +1543,15 @@ window.__ModuleLoader__.load({
1435
1543
  // 应用最终高度:以定位时采用的 ph 为准,再按落点与视口底缘的剩余空间收紧
1436
1544
  const finalH = Math.max(PANEL_MIN_H, Math.min(ph, vh - best.y - M - BORDER_Y))
1437
1545
  panel.style.maxHeight = finalH + 'px'
1438
- if (typeof state.panelH === 'number') panel.style.height = finalH + 'px'
1439
- // 重算高度/位置会让浏览器把超界 scrollTop 归零;按重排前的值恢复
1440
- const maxScroll = Math.max(0, panelBody.scrollHeight - panelBody.clientHeight)
1441
- panelBody.scrollTop = Math.min(scrollTop, maxScroll)
1546
+ if (fixedH) panel.style.height = finalH + 'px'
1547
+ // 重算高度/位置会让浏览器把超界 scrollTop 归零;按重排前的值恢复。
1548
+ // 面板本来在顶部(scrollTop=0)时无需读 scrollHeight 触发布局。
1549
+ if (scrollTop > 0) {
1550
+ const maxScroll = Math.max(0, panelBody.scrollHeight - panelBody.clientHeight)
1551
+ panelBody.scrollTop = Math.min(scrollTop, maxScroll)
1552
+ } else {
1553
+ panelBody.scrollTop = 0
1554
+ }
1442
1555
  }
1443
1556
  function togglePanel(open) {
1444
1557
  state.open = open
@@ -1448,8 +1561,15 @@ window.__ModuleLoader__.load({
1448
1561
  if (open) {
1449
1562
  renderAll()
1450
1563
  rebuildFontPick()
1451
- refreshWidthTargets()
1452
1564
  placePanel()
1565
+ // 观察器接线不影响首帧可读性:挪到下一帧,避免打开瞬间与定位抢布局。
1566
+ if (typeof window.requestAnimationFrame === 'function') {
1567
+ window.requestAnimationFrame(() => {
1568
+ if (!disposed && state.open) refreshWidthTargets()
1569
+ })
1570
+ } else {
1571
+ refreshWidthTargets()
1572
+ }
1453
1573
  } else {
1454
1574
  refreshWidthTargets()
1455
1575
  }
@@ -1563,6 +1683,19 @@ window.__ModuleLoader__.load({
1563
1683
  topToggle.addEventListener('change', applyHideTop)
1564
1684
  bindRowToggle(rowTop, topToggle, applyHideTop)
1565
1685
 
1686
+ // 快捷键动作由当前实体提供:面板关着也能切换,下次打开时开关已同步
1687
+ const shortcutHandlers = {
1688
+ hideComposer: () => {
1689
+ hideToggle.checked = !hideToggle.checked
1690
+ applyHideComposer()
1691
+ },
1692
+ hideTop: () => {
1693
+ topToggle.checked = !topToggle.checked
1694
+ applyHideTop()
1695
+ },
1696
+ }
1697
+ shortcutActions = shortcutHandlers
1698
+
1566
1699
  // ── 功能 6:全屏(开关与真实全屏状态双向同步;Esc 退出后自动回位) ──
1567
1700
  function inPageFullscreen() {
1568
1701
  return document.fullscreenElement === document.documentElement
@@ -1792,6 +1925,7 @@ window.__ModuleLoader__.load({
1792
1925
  }, 0)
1793
1926
  }
1794
1927
  function onDocPointerCancel() {
1928
+ if (!officialDragging) return
1795
1929
  const restore = officialDragAdopt && state.widthPx != null
1796
1930
  officialDragging = false
1797
1931
  officialDragMoved = false
@@ -1826,8 +1960,15 @@ window.__ModuleLoader__.load({
1826
1960
  // ConversationWidthControls 同款的 ResizeObserver 观察同一节点,把覆盖值/
1827
1961
  // 官方键/官方变量的收敛压到同一批回调内,开合过程不再出现滞留窄值的中间态。
1828
1962
  let watchedColumn = null
1963
+ let watchedColumnWidth = -1
1829
1964
  const columnObserver = new ResizeObserver(() => {
1830
1965
  if (disposed) return
1966
+ // 块级容器的高度会随流式输出、折叠动画变化;宽度没变时这些回调与
1967
+ // 宽度状态无关,跳过可避免滚动/渲染期间做无谓的样式读写。
1968
+ const current = widthTargets()
1969
+ const width = current ? current.column : 0
1970
+ if (width === watchedColumnWidth) return
1971
+ watchedColumnWidth = width
1831
1972
  adoptOfficialWidthOnStart()
1832
1973
  normalizeWidthDrift()
1833
1974
  if (state.widthPx != null && !widthTargets()) scheduleWidthTargetRetry()
@@ -1840,6 +1981,7 @@ window.__ModuleLoader__.load({
1840
1981
  if (container === watchedColumn) return
1841
1982
  columnObserver.disconnect()
1842
1983
  watchedColumn = container
1984
+ watchedColumnWidth = -1
1843
1985
  if (container) columnObserver.observe(container)
1844
1986
  }
1845
1987
 
@@ -2141,13 +2283,23 @@ window.__ModuleLoader__.load({
2141
2283
  }
2142
2284
  return walk(root)
2143
2285
  }
2286
+ function texMarkdown(tex, display) {
2287
+ return (display ? '$$' : '$') + tex + (display ? '$$' : '$')
2288
+ }
2144
2289
  let turndownSvc = null
2145
- /** 懒建并复用 turndown 实例(标准库负责标签映射;仅补一条公式规则——
2146
- * .katex / .katex-display 由 KaTeX 的 annotation 源码出口还原)。 */
2290
+ /** 懒建并复用 turndown 实例(标准库负责标签映射;补公式还原与表格单元格管道符转义)。 */
2147
2291
  function getTurndown() {
2148
2292
  if (turndownSvc) return turndownSvc
2149
2293
  turndownSvc = new TurndownService({ codeBlockStyle: 'fenced', headingStyle: 'atx', bulletListMarker: '-' })
2150
2294
  turndownSvc.use(turndownPluginGfm.gfm) // GFM 扩展:表格 / 删除线 / 任务列表(官方配套插件,见文件尾 vendored 段)
2295
+ // vendored gfm 的单元格规则不转义 |,含管道符的单元格复制后会断列;在这里覆盖。
2296
+ turndownSvc.addRule('tableCellPipes', {
2297
+ filter: ['th', 'td'],
2298
+ replacement: (content, node) => {
2299
+ const prefix = node.cellIndex === 0 ? '| ' : ' '
2300
+ return prefix + content.replace(/\|/g, '\\|') + ' |'
2301
+ },
2302
+ })
2151
2303
  turndownSvc.addRule('katex', {
2152
2304
  filter: (node) => {
2153
2305
  const cls = String((node && node.className) || '')
@@ -2156,7 +2308,8 @@ window.__ModuleLoader__.load({
2156
2308
  replacement: (_content, node) => {
2157
2309
  const tex = findTexSource(node)
2158
2310
  if (!tex) return ''
2159
- return String(node.className).indexOf('katex-display') !== -1 ? '\n$$' + tex + '$$\n' : '$' + tex + '$'
2311
+ const display = String(node.className).indexOf('katex-display') !== -1
2312
+ return display ? '\n' + texMarkdown(tex, true) + '\n' : texMarkdown(tex, false)
2160
2313
  },
2161
2314
  })
2162
2315
  return turndownSvc
@@ -2185,30 +2338,197 @@ window.__ModuleLoader__.load({
2185
2338
  }
2186
2339
  return out.join('\n').trim()
2187
2340
  }
2341
+ /** 向上找选区端点最近的匹配元素。 */
2342
+ function closestFrom(node, selector, elOf) {
2343
+ const el = elOf(node)
2344
+ return el && el.closest ? el.closest(selector) : null
2345
+ }
2346
+ /** 选区所在的块级上下文:引用层数与最近的标题级别。 */
2347
+ function blockContext(node) {
2348
+ let quotes = 0
2349
+ let heading = 0
2350
+ let el = node && (node.nodeType === 1 ? node : node.parentElement)
2351
+ while (el && !el.matches('[data-chat-node-key]')) {
2352
+ if (el.tagName === 'BLOCKQUOTE') quotes += 1
2353
+ else if (!heading) {
2354
+ const level = /^H([1-6])$/.exec(el.tagName)
2355
+ if (level) heading = Number(level[1])
2356
+ }
2357
+ el = el.parentElement
2358
+ }
2359
+ return { quotes, heading }
2360
+ }
2361
+ /** 逐行补回引用前缀(嵌套引用逐层叠加)。 */
2362
+ function quoteMarkdown(md, depth) {
2363
+ if (!depth) return md
2364
+ const line = '> '.repeat(depth)
2365
+ const blank = line.trimEnd()
2366
+ return md.split('\n').map((text) => (text ? line + text : blank)).join('\n')
2367
+ }
2368
+ function cellAlign(cell) {
2369
+ const valid = (value) => value === 'left' || value === 'right' || value === 'center'
2370
+ const explicit = (cell.getAttribute('align') || '').toLowerCase()
2371
+ if (valid(explicit)) return explicit
2372
+ const inline = cell.style && cell.style.textAlign ? cell.style.textAlign.toLowerCase() : ''
2373
+ if (valid(inline)) return inline
2374
+ const cls = String(cell.className || '')
2375
+ const named = /(?:^|\s)(?:align-|text-align-|md-align-|text-)(left|center|right)(?:\s|$)/.exec(cls)
2376
+ return named ? named[1] : ''
2377
+ }
2378
+ /** 只有 DSH 实际渲染出的 GFM 表(唯一表头、等宽行、行内单元格)才可还原。 */
2379
+ function gfmTableParts(table) {
2380
+ const head = table.tHead
2381
+ if (!head || head.rows.length !== 1 || table.tFoot || table.caption) return null
2382
+ const header = head.rows[0]
2383
+ const headerCells = [...header.cells]
2384
+ const width = headerCells.length
2385
+ if (!width || headerCells.some((cell) => cell.tagName !== 'TH')) return null
2386
+ // 单元格只能放行内内容:块结构、硬换行、独立公式都会破坏 GFM 行结构。
2387
+ const cellFits = (cell) =>
2388
+ cell.rowSpan === 1 && cell.colSpan === 1 &&
2389
+ !cell.querySelector('table, ul, ol, pre, blockquote, p, div, h1, h2, h3, h4, h5, h6, hr, br, .katex-display')
2390
+ const rowFits = (row) =>
2391
+ row.cells.length === width && [...row.cells].every(cellFits)
2392
+ const rows = [...table.rows]
2393
+ if (rows[0] !== header || !rows.every(rowFits)) return null
2394
+ return { header, headerCells, rows }
2395
+ }
2396
+ /** 表格:按显示结构还原 GFM,只补被选中的整行与原表头;非 GFM 结构交还原生复制。 */
2397
+ function tableReplacement(range, elOf) {
2398
+ const startTable = closestFrom(range.startContainer, 'table', elOf)
2399
+ const endTable = closestFrom(range.endContainer, 'table', elOf)
2400
+ if (!startTable && !endTable) return null
2401
+ if (!startTable || !endTable || startTable !== endTable) return false
2402
+ const startCell = closestFrom(range.startContainer, 'td,th', elOf)
2403
+ const endCell = closestFrom(range.endContainer, 'td,th', elOf)
2404
+ if (!startCell || !endCell) return false
2405
+ // 单个单元格内部按普通行内内容复制,不扩成整表。
2406
+ if (startCell === endCell) return null
2407
+ const startRow = startCell.closest('tr')
2408
+ const endRow = endCell.closest('tr')
2409
+ if (!startRow || !endRow) return false
2410
+ const parts = gfmTableParts(startTable)
2411
+ if (!parts) return false
2412
+ const { header, headerCells, rows } = parts
2413
+ const si = rows.indexOf(startRow)
2414
+ const ei = rows.indexOf(endRow)
2415
+ if (si < 0 || ei < 0) return false
2416
+ const lo = Math.min(si, ei)
2417
+ const hi = Math.max(si, ei)
2418
+ const body = rows.slice(lo, hi + 1).filter((row) => row !== header)
2419
+ const box = document.createElement('div')
2420
+ const table = document.createElement('table')
2421
+ const thead = document.createElement('thead')
2422
+ const headRow = document.createElement('tr')
2423
+ for (const cell of headerCells) headRow.append(cell.cloneNode(true))
2424
+ thead.append(headRow)
2425
+ table.append(thead)
2426
+ if (body.length) {
2427
+ const tbody = document.createElement('tbody')
2428
+ for (const row of body) {
2429
+ const tr = document.createElement('tr')
2430
+ for (const cell of row.cells) tr.append(cell.cloneNode(true))
2431
+ tbody.append(tr)
2432
+ }
2433
+ table.append(tbody)
2434
+ }
2435
+ box.append(table)
2436
+ return box
2437
+ }
2438
+ /** 列表:跨多个列表项时补齐完整项与 ol 起始序号;单项内部保持普通文本。 */
2439
+ function listReplacement(range, elOf) {
2440
+ const startItem = closestFrom(range.startContainer, 'li', elOf)
2441
+ const endItem = closestFrom(range.endContainer, 'li', elOf)
2442
+ if (!startItem && !endItem) return null
2443
+ if (!startItem || !endItem) return false
2444
+ const startList = startItem.parentElement
2445
+ const endList = endItem.parentElement
2446
+ if (!startList || startList !== endList || !/^(UL|OL)$/.test(startList.tagName)) return false
2447
+ if (startItem === endItem) return null
2448
+ const items = [...startList.children].filter((child) => child.tagName === 'LI')
2449
+ const si = items.indexOf(startItem)
2450
+ const ei = items.indexOf(endItem)
2451
+ if (si < 0 || ei < 0) return false
2452
+ const lo = Math.min(si, ei)
2453
+ const hi = Math.max(si, ei)
2454
+ const ordered = startList.tagName === 'OL'
2455
+ const list = document.createElement(ordered ? 'ol' : 'ul')
2456
+ if (ordered) {
2457
+ const start = Number.parseInt(startList.getAttribute('start') || '1', 10)
2458
+ const first = lo + (Number.isFinite(start) ? start : 1)
2459
+ if (first !== 1) list.setAttribute('start', String(first))
2460
+ }
2461
+ for (const item of items.slice(lo, hi + 1)) list.append(item.cloneNode(true))
2462
+ const box = document.createElement('div')
2463
+ box.append(list)
2464
+ return box
2465
+ }
2466
+ function structuralReplacement(range, elOf) {
2467
+ const table = tableReplacement(range, elOf)
2468
+ if (table === false) return false
2469
+ if (table) return table
2470
+ return listReplacement(range, elOf)
2471
+ }
2472
+ /** DSH 的代码语言只写在横幅里(pre/code 上没有 language-* 类)。 */
2473
+ function dshCodeLanguage(pre) {
2474
+ const content = pre.closest('[data-code-block-content]')
2475
+ const block = content ? content.parentElement : pre.closest('.md-code-block')
2476
+ const banner = block ? block.querySelector('[data-code-block-banner]') : null
2477
+ if (!banner) return ''
2478
+ const info = banner.querySelector('[data-text-interaction-exclude]')
2479
+ const span = banner.querySelector('span[class*="language"]') || banner.querySelector('span')
2480
+ const raw = (info ? info.textContent : span ? span.textContent : '').trim()
2481
+ // 无语言时官方横幅显示本地化的“代码/Code”,不能当围栏语言。
2482
+ if (!/^[A-Za-z0-9_+#.-]{1,30}$/.test(raw) || /^code$/i.test(raw)) return ''
2483
+ return raw
2484
+ }
2485
+ /** 把官方横幅语言补成 language-* 类(转换器只认这个),并移除横幅等非正文 UI。 */
2486
+ function normalizeCodeBlocks(root) {
2487
+ for (const pre of root.querySelectorAll('pre')) {
2488
+ const code = pre.firstElementChild
2489
+ if (!code || code.tagName !== 'CODE') continue
2490
+ if (/(^|\s)language-\S+/.test(code.getAttribute('class') || '')) continue
2491
+ const lang = pre.getAttribute('data-dsrb-lang') || dshCodeLanguage(pre)
2492
+ if (lang) code.className = (code.className ? code.className + ' ' : '') + 'language-' + lang
2493
+ }
2494
+ for (const banner of [...root.querySelectorAll('[data-code-block-banner]')]) banner.remove()
2495
+ }
2496
+ /** 校验选区里的所有表格并把对齐落到 align;非 GFM 表返回 false。 */
2497
+ function normalizeGfmTables(root) {
2498
+ for (const table of root.querySelectorAll('table')) {
2499
+ const parts = gfmTableParts(table)
2500
+ if (!parts) return false
2501
+ for (const cell of parts.headerCells) {
2502
+ const align = cellAlign(cell)
2503
+ if (align) cell.setAttribute('align', align)
2504
+ }
2505
+ }
2506
+ return true
2507
+ }
2188
2508
  function onCopy(e) {
2189
2509
  try {
2190
2510
  const sel = document.getSelection()
2191
2511
  if (!sel || sel.isCollapsed || !sel.rangeCount) return
2512
+ const elOf = (n) => (n && n.nodeType === 3 ? n.parentElement : n)
2192
2513
  // 选区两端都要在消息行内才接管(跨区选择保持系统默认行为)
2193
2514
  const inChat = (n) => {
2194
- const node = n && (n.nodeType === 3 ? n.parentElement : n)
2515
+ const node = elOf(n)
2195
2516
  return !!(node && node.closest && node.closest('[data-chat-node-key]'))
2196
2517
  }
2197
2518
  if (!inChat(sel.anchorNode) || !inChat(sel.focusNode)) return
2198
2519
  // 可编辑区域(输入框/富文本编辑器)保持原生复制
2199
2520
  const inEditable = (n) => {
2200
- const node = n && (n.nodeType === 3 ? n.parentElement : n)
2521
+ const node = elOf(n)
2201
2522
  return !!(node && node.closest && node.closest('input, textarea, [contenteditable="true"], [contenteditable=""], [contenteditable="plaintext-only"]'))
2202
2523
  }
2203
2524
  if (inEditable(sel.anchorNode) || inEditable(sel.focusNode)) return
2204
- const elOf = (n) => (n && n.nodeType === 3 ? n.parentElement : n)
2205
2525
  const ka = elOf(sel.anchorNode).closest('.katex')
2206
2526
  const kf = elOf(sel.focusNode).closest('.katex')
2207
2527
  // 只选中了某个公式本身(两端落在同一个公式内)→ 直接输出该公式的 LaTeX
2208
2528
  if (ka && ka === kf) {
2209
2529
  const tex = findTexSource(ka)
2210
2530
  if (tex) {
2211
- e.clipboardData.setData('text/plain', ka.closest('.katex-display') ? '$$' + tex + '$$' : '$' + tex + '$')
2531
+ e.clipboardData.setData('text/plain', texMarkdown(tex, !!ka.closest('.katex-display')))
2212
2532
  e.clipboardData.setData('text/html', ka.outerHTML) // 富目标(Word 等)粘贴仍显示公式
2213
2533
  e.preventDefault()
2214
2534
  return
@@ -2226,33 +2546,58 @@ window.__ModuleLoader__.load({
2226
2546
  const ke = katexAt(work.endContainer)
2227
2547
  if (ks) work.setStartBefore(ks)
2228
2548
  if (ke) work.setEndAfter(ke)
2229
- const box = document.createElement('div')
2230
- // 选区克隆丢掉共同祖先往上的层级(代码块外层的 <pre> 即如此丢的),转换器的
2231
- // 空白折叠随即把换行折掉;按现场回收"预格式化"祖先链(判据=实际生效的
2232
- // white-space)补壳还原层级——非预格式化选区零改动。
2233
- const cac = work.commonAncestorContainer
2234
- let anc = cac.nodeType === 1 ? cac : cac.parentElement
2235
- const ancShells = []
2236
- while (anc && anc !== document.body && !anc.matches('[data-chat-node-key]')) {
2237
- ancShells.push(anc)
2238
- anc = anc.parentElement
2239
- }
2240
- let topShell = -1
2241
- for (let i = 0; i < ancShells.length; i++) {
2242
- const ws = getComputedStyle(ancShells[i]).whiteSpace
2243
- if (ws === 'pre' || ws === 'pre-wrap' || ws === 'pre-line' || ws === 'break-spaces') topShell = i
2244
- }
2245
- let nestAt = box
2246
- for (let i = topShell; i >= 0; i--) {
2247
- const shell = ancShells[i].cloneNode(false)
2248
- nestAt.append(shell)
2249
- nestAt = shell
2549
+ const structural = structuralReplacement(work, elOf)
2550
+ if (structural === false) return // 不支持的结构:交还原生复制(纯文本)
2551
+ let box
2552
+ if (structural) {
2553
+ box = structural
2554
+ } else {
2555
+ box = document.createElement('div')
2556
+ // 选区克隆丢掉共同祖先往上的层级(代码块外层的 <pre> 即如此丢的),转换器的
2557
+ // 空白折叠随即把换行折掉;按现场回收"预格式化"祖先链(判据=实际生效的
2558
+ // white-space)补壳还原层级——非预格式化选区零改动。
2559
+ const cac = work.commonAncestorContainer
2560
+ let anc = cac.nodeType === 1 ? cac : cac.parentElement
2561
+ const ancShells = []
2562
+ while (anc && anc !== document.body && !anc.matches('[data-chat-node-key]')) {
2563
+ ancShells.push(anc)
2564
+ anc = anc.parentElement
2565
+ }
2566
+ let topShell = -1
2567
+ let inlineShell = -1
2568
+ const INLINE_SHELL = new Set(['CODE', 'STRONG', 'B', 'EM', 'I', 'DEL', 'S', 'STRIKE', 'A'])
2569
+ for (let i = 0; i < ancShells.length; i++) {
2570
+ const ws = getComputedStyle(ancShells[i]).whiteSpace
2571
+ if (ws === 'pre' || ws === 'pre-wrap' || ws === 'pre-line' || ws === 'break-spaces') topShell = i
2572
+ // 行内语义元素同样会在克隆时丢外壳:<code> 补 `…`,粗体/斜体/
2573
+ // 删除线/链接补对应 Markdown;取最外层,嵌套格式才能完整保留。
2574
+ if (INLINE_SHELL.has(ancShells[i].tagName)) inlineShell = i
2575
+ }
2576
+ if (topShell < 0 && inlineShell >= 0) topShell = inlineShell
2577
+ let nestAt = box
2578
+ for (let i = topShell; i >= 0; i--) {
2579
+ const shell = ancShells[i].cloneNode(false)
2580
+ if (shell.nodeName === 'PRE') {
2581
+ const lang = dshCodeLanguage(ancShells[i])
2582
+ if (lang) shell.setAttribute('data-dsrb-lang', lang)
2583
+ }
2584
+ nestAt.append(shell)
2585
+ nestAt = shell
2586
+ }
2587
+ nestAt.append(work.cloneContents())
2250
2588
  }
2251
- nestAt.append(work.cloneContents())
2589
+ normalizeCodeBlocks(box)
2590
+ // 通用/列表结构里若夹带非 GFM 表格(含整表被框进选区的情况),
2591
+ // 不能让 Turndown 退回 HTML 表或拼出坏表——整段交还原生复制。
2592
+ if (!normalizeGfmTables(box)) return
2252
2593
  // 公式场景先留存原始 HTML(含 <math>):粘进 Word 等富目标仍可显示公式
2253
2594
  const html = box.querySelector('.katex') ? box.innerHTML : null
2254
- const md = tidyMarkdown(getTurndown().turndown(box))
2595
+ let md = tidyMarkdown(getTurndown().turndown(box))
2255
2596
  if (!md) return
2597
+ // 克隆只带走被选中的子节点,会丢掉选区所在的引用/标题外壳;按现场补回块级语义。
2598
+ const context = blockContext(work.commonAncestorContainer)
2599
+ if (context.heading && !md.includes('\n')) md = '#'.repeat(context.heading) + ' ' + md
2600
+ md = quoteMarkdown(md, context.quotes)
2256
2601
  e.clipboardData.setData('text/plain', md)
2257
2602
  if (html) e.clipboardData.setData('text/html', html)
2258
2603
  e.preventDefault()
@@ -2292,8 +2637,8 @@ window.__ModuleLoader__.load({
2292
2637
  // 宽度目标只随事件变化:启动、面板打开、窗口尺寸、会话切换、官方手柄操作。
2293
2638
  refreshWidthTargets()
2294
2639
 
2295
- // 启动标记:记录加载时刻与版本(供诊断确认插件已加载;不影响功能)
2296
- writeState({ lastBootAt: Date.now(), lastBootVersion: VERSION })
2640
+ // 启动版本标记:供诊断确认插件已加载(不影响功能)
2641
+ writeState({ lastBootVersion: VERSION })
2297
2642
 
2298
2643
  // ── 卸载清理(React 槽位卸载 / 插件热重载 / ctx.effect 三路都会触发;
2299
2644
  // 幂等,可重复调用) ──
@@ -2302,6 +2647,8 @@ window.__ModuleLoader__.load({
2302
2647
  disposed = true
2303
2648
  overlayAlive = false
2304
2649
  ui = null
2650
+ if (shortcutActions === shortcutHandlers) shortcutActions = null
2651
+ if (shortcutHintNodes === shortcutHintMap) shortcutHintNodes = null
2305
2652
  if (widthRefreshTimer) { clearTimeout(widthRefreshTimer); widthRefreshTimer = null }
2306
2653
  if (widthTargetRetry) { clearTimeout(widthTargetRetry); widthTargetRetry = null }
2307
2654
  cancelFontRetry()
@@ -2391,7 +2738,7 @@ window.__ModuleLoader__.load({
2391
2738
  }
2392
2739
 
2393
2740
  // ══ vendored: turndown start ══
2394
- // turndown 7.2.4(MIT License)——逐字内联自官方发行版 dist/turndown.js。
2741
+ // turndown 7.2.5(MIT License)——逐字内联自官方发行版 dist/turndown.js。
2395
2742
  // 用途:选区复制时把渲染后的 HTML 按社区标准规则映射回 Markdown(不再是手写标签规则)。
2396
2743
  // 官方仓库:https://github.com/mixmark-io/turndown
2397
2744
  /*
@@ -3133,8 +3480,8 @@ var TurndownService = (function () {
3133
3480
  * Reduces a DOM node down to its Markdown string equivalent
3134
3481
  * @private
3135
3482
  * @param {HTMLElement} parentNode The node to convert
3136
- * @returns A Markdown representation of the node
3137
- * @type String
3483
+ * @returns Accumulator with Markdown representation parts of the node
3484
+ * @type Array
3138
3485
  */
3139
3486
 
3140
3487
  function process(parentNode) {
@@ -3147,14 +3494,15 @@ var TurndownService = (function () {
3147
3494
  } else if (node.nodeType === 1) {
3148
3495
  replacement = replacementForNode.call(self, node);
3149
3496
  }
3150
- return join(output, replacement);
3151
- }, '');
3497
+ join(output, replacement);
3498
+ return output;
3499
+ }, []);
3152
3500
  }
3153
3501
 
3154
3502
  /**
3155
3503
  * Appends strings as each rule requires and trims the output
3156
3504
  * @private
3157
- * @param {String} output The conversion output
3505
+ * @param {Array} output The conversion output accumulator
3158
3506
  * @returns A trimmed version of the ouput
3159
3507
  * @type String
3160
3508
  */
@@ -3163,10 +3511,10 @@ var TurndownService = (function () {
3163
3511
  var self = this;
3164
3512
  this.rules.forEach(function (rule) {
3165
3513
  if (typeof rule.append === 'function') {
3166
- output = join(output, rule.append(self.options));
3514
+ join(output, rule.append(self.options));
3167
3515
  }
3168
3516
  });
3169
- return output.replace(/^[\t\r\n]+/, '').replace(/[\t\r\n\s]+$/, '');
3517
+ return output.join('').replace(/^[\t\r\n]+/, '').replace(/[\t\r\n\s]+$/, '');
3170
3518
  }
3171
3519
 
3172
3520
  /**
@@ -3179,27 +3527,29 @@ var TurndownService = (function () {
3179
3527
 
3180
3528
  function replacementForNode(node) {
3181
3529
  var rule = this.rules.forNode(node);
3182
- var content = process.call(this, node);
3530
+ var content = process.call(this, node).join('');
3183
3531
  var whitespace = node.flankingWhitespace;
3184
3532
  if (whitespace.leading || whitespace.trailing) content = content.trim();
3185
3533
  return whitespace.leading + rule.replacement(content, node, this.options) + whitespace.trailing;
3186
3534
  }
3187
3535
 
3188
3536
  /**
3189
- * Joins replacement to the current output with appropriate number of new lines
3537
+ * Joins replacement with the current output accumulator using the appropriate number of new lines
3538
+ * (makes sure the only trailing whitespace will be in the last array element)
3190
3539
  * @private
3191
- * @param {String} output The current conversion output
3540
+ * @param {String} output The current conversion output accumulator
3192
3541
  * @param {String} replacement The string to append to the output
3193
- * @returns Joined output
3194
- * @type String
3195
3542
  */
3196
3543
 
3197
3544
  function join(output, replacement) {
3198
- var s1 = trimTrailingNewlines(output);
3545
+ var tail = output.pop() || '';
3546
+ var s1 = trimTrailingNewlines(tail);
3199
3547
  var s2 = trimLeadingNewlines(replacement);
3200
- var nls = Math.max(output.length - s1.length, replacement.length - s2.length);
3548
+ var nls = Math.max(tail.length - s1.length, replacement.length - s2.length);
3201
3549
  var separator = '\n\n'.substring(0, nls);
3202
- return s1 + separator + s2;
3550
+ if (s1) output.push(s1);
3551
+ if (separator) output.push(separator);
3552
+ if (s2) output.push(s2);
3203
3553
  }
3204
3554
 
3205
3555
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-happy-reader",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "Happy Reader(阅读增强):隐藏输入框 / 上边栏、全屏切换、字号 10–40px、行距/字距微调、面板可拖动可拉高、复制为 Markdown(公式→LaTeX)、内容宽度扩展、中英文分别选正文字体 + 官方代码/终端字体与字号(仅列本机已装)—— 与官方设置协同,不冲突",
5
5
  "type": "module",
6
6
  "license": "MIT",