dsh-happy-reader 0.3.2 → 0.3.3

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 +5 -1
  2. package/lib/client.js +162 -35
  3. package/package.json +1 -1
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)改写剪贴板,
@@ -167,7 +171,7 @@ dsh plugin --profile <profile> add dsh-happy-reader
167
171
  (面板避让的是 Windows 系统按钮条,字体候选为 Windows 常用字体)。
168
172
  - 依赖官方客户端界面结构,DSH 版本升级后可能需要跟随更新。
169
173
  - 运行时按事件工作:无常驻定时轮询;宽度观察器只在面板打开或扩展宽度生效时挂载,
170
- 字体 token 晚就绪用有限次退避重试(成功后停止)。
174
+ 纯高度变化不触发宽度逻辑;字体 token 晚就绪用有限次退避重试(成功后停止)。
171
175
 
172
176
  ## 许可
173
177
 
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.3'
25
25
  const INSTANCE_TOKEN = {}
26
26
  const GROUPS_VERSION = 3
27
27
  const STATE_KEY = 'dsh.reader-boost.v1'
@@ -1561,15 +1561,9 @@ window.__ModuleLoader__.load({
1561
1561
  if (open) {
1562
1562
  renderAll()
1563
1563
  rebuildFontPick()
1564
+ // 先归位宽度再显示面板:避免面板出现后下一帧才改聊天宽度,造成背后内容跳动
1565
+ refreshWidthTargets()
1564
1566
  placePanel()
1565
- // 观察器接线不影响首帧可读性:挪到下一帧,避免打开瞬间与定位抢布局。
1566
- if (typeof window.requestAnimationFrame === 'function') {
1567
- window.requestAnimationFrame(() => {
1568
- if (!disposed && state.open) refreshWidthTargets()
1569
- })
1570
- } else {
1571
- refreshWidthTargets()
1572
- }
1573
1567
  } else {
1574
1568
  refreshWidthTargets()
1575
1569
  }
@@ -1961,10 +1955,17 @@ window.__ModuleLoader__.load({
1961
1955
  // 官方键/官方变量的收敛压到同一批回调内,开合过程不再出现滞留窄值的中间态。
1962
1956
  let watchedColumn = null
1963
1957
  let watchedColumnWidth = -1
1964
- const columnObserver = new ResizeObserver(() => {
1958
+ let watchedContentWidth = -1
1959
+ const columnObserver = new ResizeObserver((entries) => {
1965
1960
  if (disposed) return
1966
1961
  // 块级容器的高度会随流式输出、折叠动画变化;宽度没变时这些回调与
1967
- // 宽度状态无关,跳过可避免滚动/渲染期间做无谓的样式读写。
1962
+ // 宽度状态无关,直接跳过(用观测值判断,避免为读 offsetWidth 触发布局)。
1963
+ const entry = entries && entries.length ? entries[entries.length - 1] : null
1964
+ const observed = entry && entry.contentRect ? entry.contentRect.width : null
1965
+ if (observed !== null) {
1966
+ if (observed === watchedContentWidth) return
1967
+ watchedContentWidth = observed
1968
+ }
1968
1969
  const current = widthTargets()
1969
1970
  const width = current ? current.column : 0
1970
1971
  if (width === watchedColumnWidth) return
@@ -1982,6 +1983,7 @@ window.__ModuleLoader__.load({
1982
1983
  columnObserver.disconnect()
1983
1984
  watchedColumn = container
1984
1985
  watchedColumnWidth = -1
1986
+ watchedContentWidth = -1
1985
1987
  if (container) columnObserver.observe(container)
1986
1988
  }
1987
1989
 
@@ -2393,15 +2395,36 @@ window.__ModuleLoader__.load({
2393
2395
  if (rows[0] !== header || !rows.every(rowFits)) return null
2394
2396
  return { header, headerCells, rows }
2395
2397
  }
2398
+ /** 用原表的结构化表头 + 指定行区间生成一张完整 GFM 表(单元格行内内容)。 */
2399
+ function buildGfmTable(parts, lo, hi) {
2400
+ const { header, headerCells, rows } = parts
2401
+ const body = rows.slice(Math.max(0, lo), Math.min(rows.length - 1, hi) + 1).filter((row) => row !== header)
2402
+ const table = document.createElement('table')
2403
+ const thead = document.createElement('thead')
2404
+ const headRow = document.createElement('tr')
2405
+ for (const cell of headerCells) headRow.append(cell.cloneNode(true))
2406
+ thead.append(headRow)
2407
+ table.append(thead)
2408
+ if (body.length) {
2409
+ const tbody = document.createElement('tbody')
2410
+ for (const row of body) {
2411
+ const tr = document.createElement('tr')
2412
+ for (const cell of row.cells) tr.append(cell.cloneNode(true))
2413
+ tbody.append(tr)
2414
+ }
2415
+ table.append(tbody)
2416
+ }
2417
+ return table
2418
+ }
2396
2419
  /** 表格:按显示结构还原 GFM,只补被选中的整行与原表头;非 GFM 结构交还原生复制。 */
2397
2420
  function tableReplacement(range, elOf) {
2398
2421
  const startTable = closestFrom(range.startContainer, 'table', elOf)
2399
2422
  const endTable = closestFrom(range.endContainer, 'table', elOf)
2400
2423
  if (!startTable && !endTable) return null
2401
- if (!startTable || !endTable || startTable !== endTable) return false
2424
+ if (!startTable || !endTable || startTable !== endTable) return null // 跨表/混合选区走通用转换,不能整段退回原生
2402
2425
  const startCell = closestFrom(range.startContainer, 'td,th', elOf)
2403
2426
  const endCell = closestFrom(range.endContainer, 'td,th', elOf)
2404
- if (!startCell || !endCell) return false
2427
+ if (!startCell || !endCell) return null
2405
2428
  // 单个单元格内部按普通行内内容复制,不扩成整表。
2406
2429
  if (startCell === endCell) return null
2407
2430
  const startRow = startCell.closest('tr')
@@ -2409,41 +2432,50 @@ window.__ModuleLoader__.load({
2409
2432
  if (!startRow || !endRow) return false
2410
2433
  const parts = gfmTableParts(startTable)
2411
2434
  if (!parts) return false
2412
- const { header, headerCells, rows } = parts
2435
+ const rows = parts.rows
2413
2436
  const si = rows.indexOf(startRow)
2414
2437
  const ei = rows.indexOf(endRow)
2415
2438
  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
2439
  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)
2440
+ box.append(buildGfmTable(parts, Math.min(si, ei), Math.max(si, ei)))
2436
2441
  return box
2437
2442
  }
2443
+ /**
2444
+ * 混合选区一端的表只会被克隆出局部(通常缺行/缺列),GFM 校验会整段回退原生。
2445
+ * 用原表把首/尾边界表补成完整行(含表头),中间完整表保持原样。
2446
+ */
2447
+ function completeBoundaryTables(box, work, elOf) {
2448
+ const startTable = closestFrom(work.startContainer, 'table', elOf)
2449
+ const endTable = closestFrom(work.endContainer, 'table', elOf)
2450
+ if (!startTable && !endTable) return
2451
+ if (startTable && endTable && startTable === endTable) return
2452
+ const cloned = [...box.querySelectorAll('table')]
2453
+ if (!cloned.length) return
2454
+ const fill = (table, isStart) => {
2455
+ if (!table) return
2456
+ const parts = gfmTableParts(table)
2457
+ if (!parts) return
2458
+ const row = closestFrom(isStart ? work.startContainer : work.endContainer, 'tr', elOf)
2459
+ if (!row) return
2460
+ const index = parts.rows.indexOf(row)
2461
+ if (index < 0) return
2462
+ const lo = isStart ? index : 1
2463
+ const hi = isStart ? parts.rows.length - 1 : Math.max(0, index)
2464
+ const target = isStart ? cloned[0] : cloned[cloned.length - 1]
2465
+ if (target && target.parentNode) target.replaceWith(buildGfmTable(parts, lo, hi))
2466
+ }
2467
+ if (startTable) fill(startTable, true)
2468
+ if (endTable && endTable !== startTable) fill(endTable, false)
2469
+ }
2438
2470
  /** 列表:跨多个列表项时补齐完整项与 ol 起始序号;单项内部保持普通文本。 */
2439
2471
  function listReplacement(range, elOf) {
2440
2472
  const startItem = closestFrom(range.startContainer, 'li', elOf)
2441
2473
  const endItem = closestFrom(range.endContainer, 'li', elOf)
2442
2474
  if (!startItem && !endItem) return null
2443
- if (!startItem || !endItem) return false
2475
+ if (!startItem || !endItem) return null // 代码块→列表等混合选区仍应保留 Markdown
2444
2476
  const startList = startItem.parentElement
2445
2477
  const endList = endItem.parentElement
2446
- if (!startList || startList !== endList || !/^(UL|OL)$/.test(startList.tagName)) return false
2478
+ if (!startList || startList !== endList || !/^(UL|OL)$/.test(startList.tagName)) return null
2447
2479
  if (startItem === endItem) return null
2448
2480
  const items = [...startList.children].filter((child) => child.tagName === 'LI')
2449
2481
  const si = items.indexOf(startItem)
@@ -2493,6 +2525,89 @@ window.__ModuleLoader__.load({
2493
2525
  }
2494
2526
  for (const banner of [...root.querySelectorAll('[data-code-block-banner]')]) banner.remove()
2495
2527
  }
2528
+ /** 混合选区时首/尾代码块可能只克隆出 pre、没带横幅;按原 DOM 端点补语言。 */
2529
+ function tagSelectedCodeLanguages(box, work, elOf) {
2530
+ const pres = [...box.querySelectorAll('pre')]
2531
+ if (!pres.length) return
2532
+ const preAt = (node) => {
2533
+ const direct = closestFrom(node, 'pre', elOf)
2534
+ if (direct) return direct
2535
+ const block = closestFrom(node, '.md-code-block', elOf)
2536
+ return block ? block.querySelector('[data-code-block-content] pre') : null
2537
+ }
2538
+ const mark = (pre, original) => {
2539
+ if (!original || !pre || pre.getAttribute('data-dsrb-lang')) return
2540
+ const lang = dshCodeLanguage(original)
2541
+ if (!lang) return
2542
+ const text = pre.textContent
2543
+ if (text && !original.textContent.includes(text)) return
2544
+ pre.setAttribute('data-dsrb-lang', lang)
2545
+ }
2546
+ mark(pres[0], preAt(work.startContainer))
2547
+ if (pres.length > 1) mark(pres[pres.length - 1], preAt(work.endContainer))
2548
+ }
2549
+ /** 选区从嵌套列表子项开始时,克隆会带出“只有子列表、没有自身文字”的父 li;
2550
+ * 直接转换会得到 `- - 子项`。把空父项拆壳,让子列表成为顶层列表。 */
2551
+ function normalizeEmptyListItems(root) {
2552
+ for (const li of [...root.querySelectorAll('li')]) {
2553
+ const nested = [...li.children].filter((child) => /^(UL|OL)$/.test(child.tagName))
2554
+ if (!nested.length) continue
2555
+ const own = [...li.childNodes].some((node) =>
2556
+ !nested.includes(node) && (node.nodeType === 1 || (node.nodeType === 3 && node.textContent.trim())))
2557
+ if (own) continue
2558
+ li.replaceWith(...nested)
2559
+ }
2560
+ }
2561
+ /** 从任务列表项文字开始时,克隆会丢掉前面的 checkbox,`[x]` 状态随之消失;
2562
+ * 按原 li 给首/尾克隆项补回 checkbox。 */
2563
+ function tagSelectedTaskItems(root, work, elOf) {
2564
+ const cloned = [...root.querySelectorAll('li')]
2565
+ if (!cloned.length) return
2566
+ const ownCheckbox = (li) => [...li.children].find((child) => child.tagName === 'INPUT' && child.type === 'checkbox')
2567
+ const mark = (li, original) => {
2568
+ if (!li || !original) return
2569
+ const source = ownCheckbox(original)
2570
+ if (!source || ownCheckbox(li)) return
2571
+ const text = li.textContent.trim()
2572
+ if (text && !original.textContent.includes(text)) return
2573
+ const input = document.createElement('input')
2574
+ input.setAttribute('type', 'checkbox')
2575
+ input.checked = !!source.checked
2576
+ if (input.checked) input.setAttribute('checked', '')
2577
+ li.insertBefore(input, li.firstChild)
2578
+ }
2579
+ mark(cloned[0], closestFrom(work.startContainer, 'li', elOf))
2580
+ if (cloned.length > 1) mark(cloned[cloned.length - 1], closestFrom(work.endContainer, 'li', elOf))
2581
+ }
2582
+ /** 混合选区从有序列表中段开始时,克隆会保留原 start 但丢掉前面各项,
2583
+ * 序号会从 3、4 重新开始;按原列表项的真实位置改写克隆 ol 的 start。 */
2584
+ function fixOrderedListStarts(root, work, elOf) {
2585
+ const list = closestFrom(work.startContainer, 'ol', elOf)
2586
+ const item = closestFrom(work.startContainer, 'li', elOf)
2587
+ if (!list || !item) return
2588
+ const items = [...list.children].filter((child) => child.tagName === 'LI')
2589
+ const index = items.indexOf(item)
2590
+ if (index < 0) return
2591
+ const base = Number.parseInt(list.getAttribute('start') || '1', 10)
2592
+ const first = (Number.isFinite(base) ? base : 1) + index
2593
+ const target = item.textContent.trim()
2594
+ const depth = (node, stop) => {
2595
+ let n = 0
2596
+ for (let el = node.parentElement; el && el !== stop; el = el.parentElement) {
2597
+ if (el.tagName === 'UL' || el.tagName === 'OL') n += 1
2598
+ if (el.matches && el.matches('[data-chat-node-key]')) break
2599
+ }
2600
+ return n
2601
+ }
2602
+ const listDepth = depth(list)
2603
+ for (const ol of root.querySelectorAll('ol')) {
2604
+ if (depth(ol, root) !== listDepth) continue
2605
+ const firstItem = [...ol.children].find((child) => child.tagName === 'LI')
2606
+ if (!firstItem || firstItem.textContent.trim() !== target) continue
2607
+ ol.setAttribute('start', String(first))
2608
+ break
2609
+ }
2610
+ }
2496
2611
  /** 校验选区里的所有表格并把对齐落到 align;非 GFM 表返回 false。 */
2497
2612
  function normalizeGfmTables(root) {
2498
2613
  for (const table of root.querySelectorAll('table')) {
@@ -2522,6 +2637,13 @@ window.__ModuleLoader__.load({
2522
2637
  return !!(node && node.closest && node.closest('input, textarea, [contenteditable="true"], [contenteditable=""], [contenteditable="plaintext-only"]'))
2523
2638
  }
2524
2639
  if (inEditable(sel.anchorNode) || inEditable(sel.focusNode)) return
2640
+ // 代码块横幅是语言/复制按钮等 UI,不是正文;只选横幅时交还原生。
2641
+ const bannerAt = (n) => {
2642
+ const node = elOf(n)
2643
+ return node && node.closest ? node.closest('[data-code-block-banner]') : null
2644
+ }
2645
+ const bannerStart = bannerAt(sel.anchorNode)
2646
+ if (bannerStart && bannerStart === bannerAt(sel.focusNode)) return
2525
2647
  const ka = elOf(sel.anchorNode).closest('.katex')
2526
2648
  const kf = elOf(sel.focusNode).closest('.katex')
2527
2649
  // 只选中了某个公式本身(两端落在同一个公式内)→ 直接输出该公式的 LaTeX
@@ -2586,7 +2708,12 @@ window.__ModuleLoader__.load({
2586
2708
  }
2587
2709
  nestAt.append(work.cloneContents())
2588
2710
  }
2711
+ tagSelectedCodeLanguages(box, work, elOf)
2712
+ completeBoundaryTables(box, work, elOf)
2589
2713
  normalizeCodeBlocks(box)
2714
+ fixOrderedListStarts(box, work, elOf)
2715
+ normalizeEmptyListItems(box)
2716
+ tagSelectedTaskItems(box, work, elOf)
2590
2717
  // 通用/列表结构里若夹带非 GFM 表格(含整表被框进选区的情况),
2591
2718
  // 不能让 Turndown 退回 HTML 表或拼出坏表——整段交还原生复制。
2592
2719
  if (!normalizeGfmTables(box)) return
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.3",
4
4
  "description": "Happy Reader(阅读增强):隐藏输入框 / 上边栏、全屏切换、字号 10–40px、行距/字距微调、面板可拖动可拉高、复制为 Markdown(公式→LaTeX)、内容宽度扩展、中英文分别选正文字体 + 官方代码/终端字体与字号(仅列本机已装)—— 与官方设置协同,不冲突",
5
5
  "type": "module",
6
6
  "license": "MIT",