dsh-happy-reader 0.3.2 → 0.3.4

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/CHANGELOG.md ADDED
@@ -0,0 +1,55 @@
1
+ # 版本记录
2
+
3
+ 版本策略:插件版本与 DSH 客户端版本绑定。`package.json.engines.dsh` 是安装/更新时的
4
+ 硬门槛;客户端 `apply()` 里还有一次运行时闸门,手动 `file:///` 安装也会被拦住。
5
+ 旧的 DSH 用户继续装旧的插件版本,不追求“一个版本通吃”。
6
+
7
+ | 插件版本 | DSH 范围 | 状态 |
8
+ |---|---|---|
9
+ | 0.3.4 | `>=0.2.2-rc.2 <0.2.3` | 2026-11-12 已发布 |
10
+ | 0.3.3 / 0.3.2 | DSH 0.2.2-rc.1 / rc.2 | 已发布 |
11
+ | 0.3.0 / 0.3.1 | DSH 0.2.2-rc.1 | 已发布 |
12
+ | 0.2.0 – 0.2.7 | DSH 0.2.2 早期 | 已发布,保留 tag |
13
+
14
+ ## 0.3.4(2026-11-12 发布)
15
+
16
+ - 适配 DSH 0.2.2-rc.2;`engines.dsh` 收紧到 `>=0.2.2-rc.2 <0.2.3`,并加运行时版本闸门。
17
+ - 默认快捷键改为 `Ctrl/⌘+Alt+I`(隐藏输入框)/ `Ctrl/⌘+Alt+T`(隐藏上边栏)。
18
+ - 复制监听先看 `e.defaultPrevented`:官方或其它插件先处理时主动让行。
19
+ - macOS:面板与 Aa 加 `data-window-drag-exclude`(单个按钮不需要
20
+ `data-window-drag-controls`;仅基础声明,未实机验证)。
21
+ - 面板稳定性:最大化/还原/全屏切换时只按新视口夹取当前落点,不再重跑自动候选;
22
+ 拖动 Aa 不再带着已打开的面板一起移动;未拖动过的 Aa 在窗口变化后仍按右下 16px 锚定。
23
+
24
+ ## 0.3.3
25
+
26
+ - 修复混合选区被整段退回原生:代码块↔列表/正文、表头↔列表、正文↔表格边界、表格↔表格。
27
+ - 首尾代码块补语言;边界表补完整表头/整行;有序列表中段续接真实序号;
28
+ 嵌套列表子项不再输出 `- -`;任务项 checkbox 状态补回。
29
+ - 代码横幅/复制按钮的 UI 选区交还原生复制。
30
+
31
+ ## 0.3.2
32
+
33
+ - 通过官方 `ctx.shortcuts` 注册“隐藏输入框 / 隐藏上边栏”,面板显示官方键帽提示。
34
+ - 隐藏输入区改用 `visibility:hidden` 预防聚焦,官方“引用文本”不再带动会话滚动。
35
+ - 代码围栏从 DSH 横幅读取语言;表格/列表结构补全;浅色主题面板白底深字。
36
+ - 面板高度锁定、滚动区顶部跳过 `scrollHeight` 读取、宽度观察器延后一帧。
37
+
38
+ ## 0.3.1
39
+
40
+ - 修复行内代码选区复制外壳(`Elf64_Rela` 不再被转义成 `Elf64\_Rela`)。
41
+ - 记录官方原生右键“复制”与全局 copy 拦截的冲突(见 TODO)。
42
+
43
+ ## 0.3.0
44
+
45
+ - 界面迁移到官方 `shell.overlay` / `settings.general.item` 槽位;文案走官方 locale;
46
+ 开关符合 `role="switch"`;取色只用官方主题变量。
47
+ - 正文字体三组可折叠卡片(正文/代码/终端),中英分设字体,官方代码/终端字体与字号同步。
48
+ - 官方「通用设置」增加中文/西文两行正文字体;浮动面板与设置页双向同步。
49
+ - 宽度扩展、全屏补偿、引用滚动修复、热重载清理与禁用即恢复。
50
+
51
+ ## 0.2.0 – 0.2.7
52
+
53
+ 公开版本重命名后的迭代:面板开关与底部设置栏、滚动条贴右缘、隐藏上边栏收敛为
54
+ 会话头正向判据、行距/字距、面板拖动/拉高、失败回退与安装/卸载清理。逐版本提交见
55
+ GitHub tag `v0.2.0` – `v0.2.7`。
package/README.md CHANGED
@@ -103,6 +103,10 @@
103
103
  - **引用/标题**内选区补回 `>` / `#`;**表格**选区自动补整行、原表头和分隔行;
104
104
  单格/单项内部按格内/项内内容复制。DSH 聊天只渲染 GFM 表(raw HTML 按文本显示);
105
105
  跨格/整表选择时,合并单元格/无表头等非 GFM 结构整段交还原生复制,不伪造表格
106
+ - 跨代码块、列表、表格、引用/标题的混合选区保留各自结构:代码围栏、列表符号、
107
+ 行内代码和表格不会因为一端落在某类结构里就整段退化成纯文本;边界表补完整表头/
108
+ 整行,有序列表中段续接真实序号,嵌套列表不出现双项目符号,任务项 `[x]/[ ]`
109
+ 状态保留;只选中代码块顶部语言/复制按钮时交还系统原生复制
106
110
  - 只在消息区域生效;复制界面其它地方(如设置、输入框)保持系统默认行为
107
111
  - **与官方「引用文本」不冲突(v0.3.0 核对)**:官方引用是选中文字后出现的按钮,
108
112
  把纯文本插入输入框;本功能只接管消息区内的 `copy` 事件(Ctrl+C)改写剪贴板,
@@ -124,8 +128,8 @@
124
128
  ### 10. 快捷键(官方通用设置)
125
129
  - 通过官方 `ctx.shortcuts` 注册两条命令,自动出现在**设置 → 通用 → 快捷键**
126
130
  (官方“编辑快捷键”入口)里:**隐藏输入框**、**隐藏上边栏**
127
- - 桌面端默认键位:Windows/Linux `Ctrl+Shift+I` / `Ctrl+Shift+T`,
128
- macOS `⌘+Shift+I` / `⌘+Shift+T`;Web 端默认不绑定
131
+ - 桌面端默认键位:Windows/Linux `Ctrl+Alt+I` / `Ctrl+Alt+T`,
132
+ macOS `⌘+Option+I` / `⌘+Option+T`;Web 端默认不绑定
129
133
  - 在速查里可以直接录制、清除或恢复默认;如果默认键位被注册服务拒绝(与已注册
130
134
  命令冲突),插件会自动改为“未绑定”命令;若被固定操作占用,速查会标出冲突,
131
135
  仍可自行改键
@@ -163,11 +167,18 @@ dsh plugin --profile <profile> add dsh-happy-reader
163
167
 
164
168
  ## 兼容性
165
169
 
166
- - Windows + DSH 桌面端 0.2.2 开发验证(按官方客户端插件接口实现);macOS / Linux 未适配
167
- (面板避让的是 Windows 系统按钮条,字体候选为 Windows 常用字体)。
168
- - 依赖官方客户端界面结构,DSH 版本升级后可能需要跟随更新。
170
+ > 逐版本改动与 DSH 范围见 [CHANGELOG.md](CHANGELOG.md);旧版本继续保留在 npm 与 GitHub tag 上。
171
+
172
+ - **0.3.4**:DSH `0.2.2-rc.2` ≤ 版本 < `0.2.3`(`package.json.engines.dsh`);插件管理器
173
+ 会在安装/更新时拒绝旧 DSH,客户端挂载时也会再做一次版本闸门。
174
+ - **0.3.3 / 0.3.2**:DSH 0.2.2-rc.1 / rc.2;**0.3.0 / 0.3.1**:DSH 0.2.2-rc.1。
175
+ - 仍在旧 DSH 上的用户请显式安装对应旧版,例如
176
+ `dsh plugin --profile <profile> add dsh-happy-reader@0.3.3`;不要依赖 `latest` 自动升级。
177
+ - Windows + DSH 桌面端实机验证;macOS 已给面板与 Aa 加 `data-window-drag-exclude`
178
+ 基础兼容声明,但未实机验证;Linux 字体候选仍按 Windows 常用字体。
179
+ - 依赖官方客户端界面结构,DSH 版本升级后需要跟随发布对应插件版本。
169
180
  - 运行时按事件工作:无常驻定时轮询;宽度观察器只在面板打开或扩展宽度生效时挂载,
170
- 字体 token 晚就绪用有限次退避重试(成功后停止)。
181
+ 纯高度变化不触发宽度逻辑;字体 token 晚就绪用有限次退避重试(成功后停止)。
171
182
 
172
183
  ## 许可
173
184
 
package/lib/client.js CHANGED
@@ -21,7 +21,7 @@ window.__ModuleLoader__.load({
21
21
  var exports = module.exports
22
22
 
23
23
  // ── 常量 ────────────────────────────────────────────────────────────
24
- const VERSION = '0.3.2'
24
+ const VERSION = '0.3.4'
25
25
  const INSTANCE_TOKEN = {}
26
26
  const GROUPS_VERSION = 3
27
27
  const STATE_KEY = 'dsh.reader-boost.v1'
@@ -868,8 +868,32 @@ window.__ModuleLoader__.load({
868
868
  }
869
869
  }
870
870
 
871
+ /** 0.3.4 起明确声明 DSH 兼容范围;手动 file:/// 安装没有插件管理器兜底,
872
+ * 这里再做一次运行时闸门。未知版本不拦截,避免破坏开发/预览环境。 */
873
+ function dshVersionSupported(version) {
874
+ if (typeof version !== 'string' || !version) return true
875
+ const m = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/.exec(version)
876
+ if (!m) return true
877
+ const major = Number(m[1]), minor = Number(m[2]), patch = Number(m[3])
878
+ if (major !== 0 || minor !== 2 || patch !== 2) return false
879
+ if (!m[4]) return true
880
+ const pre = /^rc\.(\d+)$/.exec(m[4])
881
+ return !!(pre && Number(pre[1]) >= 2)
882
+ }
883
+
871
884
  function apply(ctx) {
872
885
  try {
886
+ const bootVersion = globalThis.__DSH_BOOT__ && globalThis.__DSH_BOOT__.release
887
+ ? globalThis.__DSH_BOOT__.release.version : undefined
888
+ if (!dshVersionSupported(bootVersion)) {
889
+ console.error('[dsh-happy-reader] 当前 DSH ' + bootVersion + ' 不在本插件支持范围(需 0.2.2-rc.2 至 0.2.2.x),已停止挂载。')
890
+ // 支持范围被收紧后热重载到本版本:旧实例不应继续留在页面上。
891
+ const staleDispose = window.__dsrbDispose
892
+ if (typeof staleDispose === 'function') {
893
+ try { staleDispose() } catch { /* 旧实例拆除失败不阻塞退出 */ }
894
+ }
895
+ return
896
+ }
873
897
  // 热重载:DSH 监视插件文件变化,会再次执行本客户端。先拆旧实例、再按 id
874
898
  // 清扫遗留节点、最后重建 —— 页面上运行的必然是最新代码。
875
899
  const prevDispose = window.__dsrbDispose
@@ -996,9 +1020,9 @@ window.__ModuleLoader__.load({
996
1020
  const shortcuts = sctx.shortcuts
997
1021
  if (!shortcuts || typeof shortcuts.register !== 'function') return
998
1022
  const desktopDefaults = (code) => ({
999
- 'desktop:macos': { code, modifiers: ['primary', 'shift'] },
1000
- 'desktop:windows': { code, modifiers: ['primary', 'shift'] },
1001
- 'desktop:linux': { code, modifiers: ['primary', 'shift'] },
1023
+ 'desktop:macos': { code, modifiers: ['primary', 'alt'] },
1024
+ 'desktop:windows': { code, modifiers: ['primary', 'alt'] },
1025
+ 'desktop:linux': { code, modifiers: ['primary', 'alt'] },
1002
1026
  })
1003
1027
  const command = (id, labelKey, aliases, code, action) => ({
1004
1028
  id,
@@ -1150,6 +1174,10 @@ window.__ModuleLoader__.load({
1150
1174
  fab.type = 'button'
1151
1175
  fab.title = t('fabTitle')
1152
1176
  fab.setAttribute('aria-label', t('fabTitle'))
1177
+ // 0.2.2-rc.1+ macOS:自定义浮层控件需显式声明窗口拖拽排除。
1178
+ // 单个按钮用 data-window-drag-exclude 即可;data-window-drag-controls 是
1179
+ // “容器内控件分别排除”的作用域,不应标在按钮自身。
1180
+ fab.setAttribute('data-window-drag-exclude', '')
1153
1181
  const [fx, fy] = clampPos(
1154
1182
  state.x ?? window.innerWidth - FAB_SIZE - 16,
1155
1183
  state.y ?? window.innerHeight - FAB_SIZE - 16,
@@ -1165,6 +1193,7 @@ window.__ModuleLoader__.load({
1165
1193
  panel.id = PANEL_ID
1166
1194
  panel.setAttribute('role', 'region')
1167
1195
  panel.setAttribute('aria-label', t('panelTitle'))
1196
+ panel.setAttribute('data-window-drag-exclude', '') // macOS:浮层可见矩形不参与窗口拖拽
1168
1197
 
1169
1198
  const headRow = el('div', 'dsrb-head')
1170
1199
  const title = el('h4', null, t('panelTitle'))
@@ -1476,9 +1505,11 @@ window.__ModuleLoader__.load({
1476
1505
  const titlebarH =
1477
1506
  Number.parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--dsh-windows-titlebar-height')) || 40
1478
1507
  const CAP_W = 138
1479
- function placePanel(keepScroll) {
1508
+ function placePanel(keepScroll, keepPosition) {
1480
1509
  const scrollTop = typeof keepScroll === 'number' ? keepScroll : panelBody.scrollTop
1481
1510
  const r = fab.getBoundingClientRect()
1511
+ const currentLeft = Number.parseFloat(panel.style.left)
1512
+ const currentTop = Number.parseFloat(panel.style.top)
1482
1513
  const fixedH = typeof state.panelH === 'number'
1483
1514
  if (!fixedH) {
1484
1515
  panel.style.height = ''
@@ -1498,14 +1529,21 @@ window.__ModuleLoader__.load({
1498
1529
  const ph = Math.max(PANEL_MIN_H, Math.min(desired, vh - 2 * M - BORDER_Y))
1499
1530
  const clampX = (x) => Math.min(Math.max(M, x), Math.max(M, vw - pw - M))
1500
1531
  const clampY = (y, yMin) => Math.min(Math.max(yMin, y), Math.max(yMin, vh - ph - BORDER_Y - M))
1532
+ const yMinFor = (x) => CAP_H > 0 && x + pw > vw - CAP_W ? CAP_H : M
1501
1533
  let best = null
1502
- if (typeof state.panelX === 'number' && typeof state.panelY === 'number') {
1534
+ // 窗口最大化/还原/全屏切换时,面板若已经开着,保持当前视觉位置并只做视口夹取;
1535
+ // 不重新跑自动候选,避免面板在窗口状态变化时跳位。
1536
+ if (keepPosition && Number.isFinite(currentLeft) && Number.isFinite(currentTop)) {
1537
+ const sx = clampX(currentLeft)
1538
+ best = { x: sx, y: clampY(currentTop, yMinFor(sx)) }
1539
+ }
1540
+ if (!best && typeof state.panelX === 'number' && typeof state.panelY === 'number') {
1503
1541
  // 用户拖过位置 → 用记住的位置;仍做两件保护:避开右上角系统按钮条;
1504
1542
  // 若与按钮重叠(按钮可能后来被拖到附近)则放弃记忆、回自动布局
1505
1543
  const sx = clampX(state.panelX)
1506
- const sy = clampY(state.panelY, CAP_H > 0 && sx + pw > vw - CAP_W ? CAP_H : M)
1544
+ const sy = clampY(state.panelY, yMinFor(sx))
1507
1545
  const overlapsFab = sx < r.right + GAP && sx + pw > r.left - GAP && sy < r.bottom + GAP && sy + ph > r.top - GAP
1508
- if (overlapsFab) {
1546
+ if (overlapsFab && !keepPosition) {
1509
1547
  state.panelX = null
1510
1548
  state.panelY = null
1511
1549
  writeState({ panelX: null, panelY: null })
@@ -1525,8 +1563,7 @@ window.__ModuleLoader__.load({
1525
1563
  x < r.right + GAP && x + pw > r.left - GAP && y < r.bottom + GAP && y + ph > r.top - GAP
1526
1564
  for (const c of candidates) {
1527
1565
  const x = clampX(c.x)
1528
- const yMin = CAP_H > 0 && x + pw > vw - CAP_W ? CAP_H : M
1529
- const y = clampY(c.y, yMin)
1566
+ const y = clampY(c.y, yMinFor(x))
1530
1567
  if (!hits(x, y)) {
1531
1568
  best = { x, y }
1532
1569
  break
@@ -1561,15 +1598,9 @@ window.__ModuleLoader__.load({
1561
1598
  if (open) {
1562
1599
  renderAll()
1563
1600
  rebuildFontPick()
1601
+ // 先归位宽度再显示面板:避免面板出现后下一帧才改聊天宽度,造成背后内容跳动
1602
+ refreshWidthTargets()
1564
1603
  placePanel()
1565
- // 观察器接线不影响首帧可读性:挪到下一帧,避免打开瞬间与定位抢布局。
1566
- if (typeof window.requestAnimationFrame === 'function') {
1567
- window.requestAnimationFrame(() => {
1568
- if (!disposed && state.open) refreshWidthTargets()
1569
- })
1570
- } else {
1571
- refreshWidthTargets()
1572
- }
1573
1604
  } else {
1574
1605
  refreshWidthTargets()
1575
1606
  }
@@ -1709,8 +1740,9 @@ window.__ModuleLoader__.load({
1709
1740
  const root = document.documentElement
1710
1741
  if (on) root.setAttribute(FS_ATTR, '')
1711
1742
  else root.removeAttribute(FS_ATTR)
1712
- // 全屏切换会改变系统按钮条的有无 → 面板开着时当场重定位
1713
- if (state.open) placePanel()
1743
+ // 全屏切换会改变系统按钮条的有无 → 面板开着时夹取到新视口;
1744
+ // 保留用户当前看到的落点,不重新跑自动候选。
1745
+ if (state.open) placePanel(undefined, true)
1714
1746
  }
1715
1747
  function applyFullscreen() {
1716
1748
  // 进入全屏必须发生在用户手势内:本函数由开关 change / 整条点击同步触发,满足
@@ -1961,10 +1993,17 @@ window.__ModuleLoader__.load({
1961
1993
  // 官方键/官方变量的收敛压到同一批回调内,开合过程不再出现滞留窄值的中间态。
1962
1994
  let watchedColumn = null
1963
1995
  let watchedColumnWidth = -1
1964
- const columnObserver = new ResizeObserver(() => {
1996
+ let watchedContentWidth = -1
1997
+ const columnObserver = new ResizeObserver((entries) => {
1965
1998
  if (disposed) return
1966
1999
  // 块级容器的高度会随流式输出、折叠动画变化;宽度没变时这些回调与
1967
- // 宽度状态无关,跳过可避免滚动/渲染期间做无谓的样式读写。
2000
+ // 宽度状态无关,直接跳过(用观测值判断,避免为读 offsetWidth 触发布局)。
2001
+ const entry = entries && entries.length ? entries[entries.length - 1] : null
2002
+ const observed = entry && entry.contentRect ? entry.contentRect.width : null
2003
+ if (observed !== null) {
2004
+ if (observed === watchedContentWidth) return
2005
+ watchedContentWidth = observed
2006
+ }
1968
2007
  const current = widthTargets()
1969
2008
  const width = current ? current.column : 0
1970
2009
  if (width === watchedColumnWidth) return
@@ -1982,6 +2021,7 @@ window.__ModuleLoader__.load({
1982
2021
  columnObserver.disconnect()
1983
2022
  watchedColumn = container
1984
2023
  watchedColumnWidth = -1
2024
+ watchedContentWidth = -1
1985
2025
  if (container) columnObserver.observe(container)
1986
2026
  }
1987
2027
 
@@ -2131,7 +2171,8 @@ window.__ModuleLoader__.load({
2131
2171
  drag = null
2132
2172
  if (wasDrag) {
2133
2173
  writeState({ x: fab.offsetLeft, y: fab.offsetTop })
2134
- if (state.open) placePanel()
2174
+ // 面板是独立浮层:拖动 Aa 时保持面板原位,不跟着按钮跑;
2175
+ // 下次打开面板再按新按钮位置自动布局。
2135
2176
  } else {
2136
2177
  togglePanel(!state.open)
2137
2178
  }
@@ -2393,15 +2434,36 @@ window.__ModuleLoader__.load({
2393
2434
  if (rows[0] !== header || !rows.every(rowFits)) return null
2394
2435
  return { header, headerCells, rows }
2395
2436
  }
2437
+ /** 用原表的结构化表头 + 指定行区间生成一张完整 GFM 表(单元格行内内容)。 */
2438
+ function buildGfmTable(parts, lo, hi) {
2439
+ const { header, headerCells, rows } = parts
2440
+ const body = rows.slice(Math.max(0, lo), Math.min(rows.length - 1, hi) + 1).filter((row) => row !== header)
2441
+ const table = document.createElement('table')
2442
+ const thead = document.createElement('thead')
2443
+ const headRow = document.createElement('tr')
2444
+ for (const cell of headerCells) headRow.append(cell.cloneNode(true))
2445
+ thead.append(headRow)
2446
+ table.append(thead)
2447
+ if (body.length) {
2448
+ const tbody = document.createElement('tbody')
2449
+ for (const row of body) {
2450
+ const tr = document.createElement('tr')
2451
+ for (const cell of row.cells) tr.append(cell.cloneNode(true))
2452
+ tbody.append(tr)
2453
+ }
2454
+ table.append(tbody)
2455
+ }
2456
+ return table
2457
+ }
2396
2458
  /** 表格:按显示结构还原 GFM,只补被选中的整行与原表头;非 GFM 结构交还原生复制。 */
2397
2459
  function tableReplacement(range, elOf) {
2398
2460
  const startTable = closestFrom(range.startContainer, 'table', elOf)
2399
2461
  const endTable = closestFrom(range.endContainer, 'table', elOf)
2400
2462
  if (!startTable && !endTable) return null
2401
- if (!startTable || !endTable || startTable !== endTable) return false
2463
+ if (!startTable || !endTable || startTable !== endTable) return null // 跨表/混合选区走通用转换,不能整段退回原生
2402
2464
  const startCell = closestFrom(range.startContainer, 'td,th', elOf)
2403
2465
  const endCell = closestFrom(range.endContainer, 'td,th', elOf)
2404
- if (!startCell || !endCell) return false
2466
+ if (!startCell || !endCell) return null
2405
2467
  // 单个单元格内部按普通行内内容复制,不扩成整表。
2406
2468
  if (startCell === endCell) return null
2407
2469
  const startRow = startCell.closest('tr')
@@ -2409,41 +2471,50 @@ window.__ModuleLoader__.load({
2409
2471
  if (!startRow || !endRow) return false
2410
2472
  const parts = gfmTableParts(startTable)
2411
2473
  if (!parts) return false
2412
- const { header, headerCells, rows } = parts
2474
+ const rows = parts.rows
2413
2475
  const si = rows.indexOf(startRow)
2414
2476
  const ei = rows.indexOf(endRow)
2415
2477
  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
2478
  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)
2479
+ box.append(buildGfmTable(parts, Math.min(si, ei), Math.max(si, ei)))
2436
2480
  return box
2437
2481
  }
2482
+ /**
2483
+ * 混合选区一端的表只会被克隆出局部(通常缺行/缺列),GFM 校验会整段回退原生。
2484
+ * 用原表把首/尾边界表补成完整行(含表头),中间完整表保持原样。
2485
+ */
2486
+ function completeBoundaryTables(box, work, elOf) {
2487
+ const startTable = closestFrom(work.startContainer, 'table', elOf)
2488
+ const endTable = closestFrom(work.endContainer, 'table', elOf)
2489
+ if (!startTable && !endTable) return
2490
+ if (startTable && endTable && startTable === endTable) return
2491
+ const cloned = [...box.querySelectorAll('table')]
2492
+ if (!cloned.length) return
2493
+ const fill = (table, isStart) => {
2494
+ if (!table) return
2495
+ const parts = gfmTableParts(table)
2496
+ if (!parts) return
2497
+ const row = closestFrom(isStart ? work.startContainer : work.endContainer, 'tr', elOf)
2498
+ if (!row) return
2499
+ const index = parts.rows.indexOf(row)
2500
+ if (index < 0) return
2501
+ const lo = isStart ? index : 1
2502
+ const hi = isStart ? parts.rows.length - 1 : Math.max(0, index)
2503
+ const target = isStart ? cloned[0] : cloned[cloned.length - 1]
2504
+ if (target && target.parentNode) target.replaceWith(buildGfmTable(parts, lo, hi))
2505
+ }
2506
+ if (startTable) fill(startTable, true)
2507
+ if (endTable && endTable !== startTable) fill(endTable, false)
2508
+ }
2438
2509
  /** 列表:跨多个列表项时补齐完整项与 ol 起始序号;单项内部保持普通文本。 */
2439
2510
  function listReplacement(range, elOf) {
2440
2511
  const startItem = closestFrom(range.startContainer, 'li', elOf)
2441
2512
  const endItem = closestFrom(range.endContainer, 'li', elOf)
2442
2513
  if (!startItem && !endItem) return null
2443
- if (!startItem || !endItem) return false
2514
+ if (!startItem || !endItem) return null // 代码块→列表等混合选区仍应保留 Markdown
2444
2515
  const startList = startItem.parentElement
2445
2516
  const endList = endItem.parentElement
2446
- if (!startList || startList !== endList || !/^(UL|OL)$/.test(startList.tagName)) return false
2517
+ if (!startList || startList !== endList || !/^(UL|OL)$/.test(startList.tagName)) return null
2447
2518
  if (startItem === endItem) return null
2448
2519
  const items = [...startList.children].filter((child) => child.tagName === 'LI')
2449
2520
  const si = items.indexOf(startItem)
@@ -2493,6 +2564,89 @@ window.__ModuleLoader__.load({
2493
2564
  }
2494
2565
  for (const banner of [...root.querySelectorAll('[data-code-block-banner]')]) banner.remove()
2495
2566
  }
2567
+ /** 混合选区时首/尾代码块可能只克隆出 pre、没带横幅;按原 DOM 端点补语言。 */
2568
+ function tagSelectedCodeLanguages(box, work, elOf) {
2569
+ const pres = [...box.querySelectorAll('pre')]
2570
+ if (!pres.length) return
2571
+ const preAt = (node) => {
2572
+ const direct = closestFrom(node, 'pre', elOf)
2573
+ if (direct) return direct
2574
+ const block = closestFrom(node, '.md-code-block', elOf)
2575
+ return block ? block.querySelector('[data-code-block-content] pre') : null
2576
+ }
2577
+ const mark = (pre, original) => {
2578
+ if (!original || !pre || pre.getAttribute('data-dsrb-lang')) return
2579
+ const lang = dshCodeLanguage(original)
2580
+ if (!lang) return
2581
+ const text = pre.textContent
2582
+ if (text && !original.textContent.includes(text)) return
2583
+ pre.setAttribute('data-dsrb-lang', lang)
2584
+ }
2585
+ mark(pres[0], preAt(work.startContainer))
2586
+ if (pres.length > 1) mark(pres[pres.length - 1], preAt(work.endContainer))
2587
+ }
2588
+ /** 选区从嵌套列表子项开始时,克隆会带出“只有子列表、没有自身文字”的父 li;
2589
+ * 直接转换会得到 `- - 子项`。把空父项拆壳,让子列表成为顶层列表。 */
2590
+ function normalizeEmptyListItems(root) {
2591
+ for (const li of [...root.querySelectorAll('li')]) {
2592
+ const nested = [...li.children].filter((child) => /^(UL|OL)$/.test(child.tagName))
2593
+ if (!nested.length) continue
2594
+ const own = [...li.childNodes].some((node) =>
2595
+ !nested.includes(node) && (node.nodeType === 1 || (node.nodeType === 3 && node.textContent.trim())))
2596
+ if (own) continue
2597
+ li.replaceWith(...nested)
2598
+ }
2599
+ }
2600
+ /** 从任务列表项文字开始时,克隆会丢掉前面的 checkbox,`[x]` 状态随之消失;
2601
+ * 按原 li 给首/尾克隆项补回 checkbox。 */
2602
+ function tagSelectedTaskItems(root, work, elOf) {
2603
+ const cloned = [...root.querySelectorAll('li')]
2604
+ if (!cloned.length) return
2605
+ const ownCheckbox = (li) => [...li.children].find((child) => child.tagName === 'INPUT' && child.type === 'checkbox')
2606
+ const mark = (li, original) => {
2607
+ if (!li || !original) return
2608
+ const source = ownCheckbox(original)
2609
+ if (!source || ownCheckbox(li)) return
2610
+ const text = li.textContent.trim()
2611
+ if (text && !original.textContent.includes(text)) return
2612
+ const input = document.createElement('input')
2613
+ input.setAttribute('type', 'checkbox')
2614
+ input.checked = !!source.checked
2615
+ if (input.checked) input.setAttribute('checked', '')
2616
+ li.insertBefore(input, li.firstChild)
2617
+ }
2618
+ mark(cloned[0], closestFrom(work.startContainer, 'li', elOf))
2619
+ if (cloned.length > 1) mark(cloned[cloned.length - 1], closestFrom(work.endContainer, 'li', elOf))
2620
+ }
2621
+ /** 混合选区从有序列表中段开始时,克隆会保留原 start 但丢掉前面各项,
2622
+ * 序号会从 3、4 重新开始;按原列表项的真实位置改写克隆 ol 的 start。 */
2623
+ function fixOrderedListStarts(root, work, elOf) {
2624
+ const list = closestFrom(work.startContainer, 'ol', elOf)
2625
+ const item = closestFrom(work.startContainer, 'li', elOf)
2626
+ if (!list || !item) return
2627
+ const items = [...list.children].filter((child) => child.tagName === 'LI')
2628
+ const index = items.indexOf(item)
2629
+ if (index < 0) return
2630
+ const base = Number.parseInt(list.getAttribute('start') || '1', 10)
2631
+ const first = (Number.isFinite(base) ? base : 1) + index
2632
+ const target = item.textContent.trim()
2633
+ const depth = (node, stop) => {
2634
+ let n = 0
2635
+ for (let el = node.parentElement; el && el !== stop; el = el.parentElement) {
2636
+ if (el.tagName === 'UL' || el.tagName === 'OL') n += 1
2637
+ if (el.matches && el.matches('[data-chat-node-key]')) break
2638
+ }
2639
+ return n
2640
+ }
2641
+ const listDepth = depth(list)
2642
+ for (const ol of root.querySelectorAll('ol')) {
2643
+ if (depth(ol, root) !== listDepth) continue
2644
+ const firstItem = [...ol.children].find((child) => child.tagName === 'LI')
2645
+ if (!firstItem || firstItem.textContent.trim() !== target) continue
2646
+ ol.setAttribute('start', String(first))
2647
+ break
2648
+ }
2649
+ }
2496
2650
  /** 校验选区里的所有表格并把对齐落到 align;非 GFM 表返回 false。 */
2497
2651
  function normalizeGfmTables(root) {
2498
2652
  for (const table of root.querySelectorAll('table')) {
@@ -2507,6 +2661,7 @@ window.__ModuleLoader__.load({
2507
2661
  }
2508
2662
  function onCopy(e) {
2509
2663
  try {
2664
+ if (e.defaultPrevented) return // 尊重先注册/先执行的复制处理(含官方公式复制)
2510
2665
  const sel = document.getSelection()
2511
2666
  if (!sel || sel.isCollapsed || !sel.rangeCount) return
2512
2667
  const elOf = (n) => (n && n.nodeType === 3 ? n.parentElement : n)
@@ -2522,6 +2677,13 @@ window.__ModuleLoader__.load({
2522
2677
  return !!(node && node.closest && node.closest('input, textarea, [contenteditable="true"], [contenteditable=""], [contenteditable="plaintext-only"]'))
2523
2678
  }
2524
2679
  if (inEditable(sel.anchorNode) || inEditable(sel.focusNode)) return
2680
+ // 代码块横幅是语言/复制按钮等 UI,不是正文;只选横幅时交还原生。
2681
+ const bannerAt = (n) => {
2682
+ const node = elOf(n)
2683
+ return node && node.closest ? node.closest('[data-code-block-banner]') : null
2684
+ }
2685
+ const bannerStart = bannerAt(sel.anchorNode)
2686
+ if (bannerStart && bannerStart === bannerAt(sel.focusNode)) return
2525
2687
  const ka = elOf(sel.anchorNode).closest('.katex')
2526
2688
  const kf = elOf(sel.focusNode).closest('.katex')
2527
2689
  // 只选中了某个公式本身(两端落在同一个公式内)→ 直接输出该公式的 LaTeX
@@ -2586,7 +2748,12 @@ window.__ModuleLoader__.load({
2586
2748
  }
2587
2749
  nestAt.append(work.cloneContents())
2588
2750
  }
2751
+ tagSelectedCodeLanguages(box, work, elOf)
2752
+ completeBoundaryTables(box, work, elOf)
2589
2753
  normalizeCodeBlocks(box)
2754
+ fixOrderedListStarts(box, work, elOf)
2755
+ normalizeEmptyListItems(box)
2756
+ tagSelectedTaskItems(box, work, elOf)
2590
2757
  // 通用/列表结构里若夹带非 GFM 表格(含整表被框进选区的情况),
2591
2758
  // 不能让 Turndown 退回 HTML 表或拼出坏表——整段交还原生复制。
2592
2759
  if (!normalizeGfmTables(box)) return
@@ -2626,11 +2793,15 @@ window.__ModuleLoader__.load({
2626
2793
  document.addEventListener('keydown', onDocKeyDown)
2627
2794
 
2628
2795
  function onResize() {
2629
- const [x, y] = clampPos(fab.offsetLeft, fab.offsetTop, FAB_SIZE, FAB_SIZE)
2796
+ // 未拖动过:Aa 始终按“右下 16px”重新锚定;拖动过:按记住的绝对位置夹取。
2797
+ const baseX = state.x == null ? window.innerWidth - FAB_SIZE - 16 : state.x
2798
+ const baseY = state.y == null ? window.innerHeight - FAB_SIZE - 16 : state.y
2799
+ const [x, y] = clampPos(baseX, baseY, FAB_SIZE, FAB_SIZE)
2630
2800
  fab.style.left = x + 'px'
2631
2801
  fab.style.top = y + 'px'
2632
2802
  refreshWidthTargets()
2633
- if (state.open) placePanel()
2803
+ // 面板已开着:保持当前视觉位置并夹取视口,避免最大化/还原时跳位。
2804
+ if (state.open) placePanel(undefined, true)
2634
2805
  }
2635
2806
  window.addEventListener('resize', onResize)
2636
2807
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-happy-reader",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
4
4
  "description": "Happy Reader(阅读增强):隐藏输入框 / 上边栏、全屏切换、字号 10–40px、行距/字距微调、面板可拖动可拉高、复制为 Markdown(公式→LaTeX)、内容宽度扩展、中英文分别选正文字体 + 官方代码/终端字体与字号(仅列本机已装)—— 与官方设置协同,不冲突",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -34,6 +34,7 @@
34
34
  "lib",
35
35
  "cordis.patch.yml",
36
36
  "README.md",
37
+ "CHANGELOG.md",
37
38
  "LICENSE",
38
39
  "locale/*.json",
39
40
  "icon.svg"
@@ -46,5 +47,8 @@
46
47
  "platform": "web",
47
48
  "immediately": true
48
49
  }
50
+ },
51
+ "engines": {
52
+ "dsh": ">=0.2.2-rc.2 <0.2.3"
49
53
  }
50
54
  }