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.
- package/README.md +5 -1
- package/lib/client.js +162 -35
- 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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
3
|
+
"version": "0.3.3",
|
|
4
4
|
"description": "Happy Reader(阅读增强):隐藏输入框 / 上边栏、全屏切换、字号 10–40px、行距/字距微调、面板可拖动可拉高、复制为 Markdown(公式→LaTeX)、内容宽度扩展、中英文分别选正文字体 + 官方代码/终端字体与字号(仅列本机已装)—— 与官方设置协同,不冲突",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|