dsh-happy-reader 0.3.1 → 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 +28 -4
- package/lib/client.js +546 -75
- 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,17 @@
|
|
|
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 结构整段交还原生复制,不伪造表格
|
|
106
|
+
- 跨代码块、列表、表格、引用/标题的混合选区保留各自结构:代码围栏、列表符号、
|
|
107
|
+
行内代码和表格不会因为一端落在某类结构里就整段退化成纯文本;边界表补完整表头/
|
|
108
|
+
整行,有序列表中段续接真实序号,嵌套列表不出现双项目符号,任务项 `[x]/[ ]`
|
|
109
|
+
状态保留;只选中代码块顶部语言/复制按钮时交还系统原生复制
|
|
99
110
|
- 只在消息区域生效;复制界面其它地方(如设置、输入框)保持系统默认行为
|
|
100
111
|
- **与官方「引用文本」不冲突(v0.3.0 核对)**:官方引用是选中文字后出现的按钮,
|
|
101
112
|
把纯文本插入输入框;本功能只接管消息区内的 `copy` 事件(Ctrl+C)改写剪贴板,
|
|
@@ -114,6 +125,19 @@
|
|
|
114
125
|
- 位置与高度自动记住;只有真正拖动过才会记住(点一下不受影响);
|
|
115
126
|
**双击标题栏**一键复位回自动布局
|
|
116
127
|
|
|
128
|
+
### 10. 快捷键(官方通用设置)
|
|
129
|
+
- 通过官方 `ctx.shortcuts` 注册两条命令,自动出现在**设置 → 通用 → 快捷键**
|
|
130
|
+
(官方“编辑快捷键”入口)里:**隐藏输入框**、**隐藏上边栏**
|
|
131
|
+
- 桌面端默认键位:Windows/Linux `Ctrl+Shift+I` / `Ctrl+Shift+T`,
|
|
132
|
+
macOS `⌘+Shift+I` / `⌘+Shift+T`;Web 端默认不绑定
|
|
133
|
+
- 在速查里可以直接录制、清除或恢复默认;如果默认键位被注册服务拒绝(与已注册
|
|
134
|
+
命令冲突),插件会自动改为“未绑定”命令;若被固定操作占用,速查会标出冲突,
|
|
135
|
+
仍可自行改键
|
|
136
|
+
- 面板里的“隐藏输入框 / 隐藏上边栏”行会在悬停或键盘聚焦时显示圆角整行高亮,
|
|
137
|
+
并在标题行右侧显示与官方新会话按钮同款的键帽提示;键位随官方设置实时刷新,
|
|
138
|
+
清除绑定后自动隐藏
|
|
139
|
+
- 命令随插件停用/卸载移除;弹窗打开时快捷键让位,不会误触发
|
|
140
|
+
|
|
117
141
|
> 推荐组合:**隐藏输入框 + 隐藏上边栏 + 全屏** —— 一键进入沉浸阅读模式。
|
|
118
142
|
|
|
119
143
|
## 安装
|
|
@@ -147,7 +171,7 @@ dsh plugin --profile <profile> add dsh-happy-reader
|
|
|
147
171
|
(面板避让的是 Windows 系统按钮条,字体候选为 Windows 常用字体)。
|
|
148
172
|
- 依赖官方客户端界面结构,DSH 版本升级后可能需要跟随更新。
|
|
149
173
|
- 运行时按事件工作:无常驻定时轮询;宽度观察器只在面板打开或扩展宽度生效时挂载,
|
|
150
|
-
|
|
174
|
+
纯高度变化不触发宽度逻辑;字体 token 晚就绪用有限次退避重试(成功后停止)。
|
|
151
175
|
|
|
152
176
|
## 许可
|
|
153
177
|
|
package/lib/client.js
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
// 面板拖动/拉高、复制为 Markdown。
|
|
7
7
|
//
|
|
8
8
|
// 关键约束:
|
|
9
|
-
// · 隐藏输入区用 height:0
|
|
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.
|
|
24
|
+
const VERSION = '0.3.3'
|
|
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
|
-
|
|
111
|
-
.dsrb-row-click
|
|
112
|
-
.dsrb-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1380
|
-
|
|
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 =
|
|
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 (
|
|
1439
|
-
// 重算高度/位置会让浏览器把超界 scrollTop
|
|
1440
|
-
|
|
1441
|
-
|
|
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,6 +1561,7 @@ window.__ModuleLoader__.load({
|
|
|
1448
1561
|
if (open) {
|
|
1449
1562
|
renderAll()
|
|
1450
1563
|
rebuildFontPick()
|
|
1564
|
+
// 先归位宽度再显示面板:避免面板出现后下一帧才改聊天宽度,造成背后内容跳动
|
|
1451
1565
|
refreshWidthTargets()
|
|
1452
1566
|
placePanel()
|
|
1453
1567
|
} else {
|
|
@@ -1563,6 +1677,19 @@ window.__ModuleLoader__.load({
|
|
|
1563
1677
|
topToggle.addEventListener('change', applyHideTop)
|
|
1564
1678
|
bindRowToggle(rowTop, topToggle, applyHideTop)
|
|
1565
1679
|
|
|
1680
|
+
// 快捷键动作由当前实体提供:面板关着也能切换,下次打开时开关已同步
|
|
1681
|
+
const shortcutHandlers = {
|
|
1682
|
+
hideComposer: () => {
|
|
1683
|
+
hideToggle.checked = !hideToggle.checked
|
|
1684
|
+
applyHideComposer()
|
|
1685
|
+
},
|
|
1686
|
+
hideTop: () => {
|
|
1687
|
+
topToggle.checked = !topToggle.checked
|
|
1688
|
+
applyHideTop()
|
|
1689
|
+
},
|
|
1690
|
+
}
|
|
1691
|
+
shortcutActions = shortcutHandlers
|
|
1692
|
+
|
|
1566
1693
|
// ── 功能 6:全屏(开关与真实全屏状态双向同步;Esc 退出后自动回位) ──
|
|
1567
1694
|
function inPageFullscreen() {
|
|
1568
1695
|
return document.fullscreenElement === document.documentElement
|
|
@@ -1792,6 +1919,7 @@ window.__ModuleLoader__.load({
|
|
|
1792
1919
|
}, 0)
|
|
1793
1920
|
}
|
|
1794
1921
|
function onDocPointerCancel() {
|
|
1922
|
+
if (!officialDragging) return
|
|
1795
1923
|
const restore = officialDragAdopt && state.widthPx != null
|
|
1796
1924
|
officialDragging = false
|
|
1797
1925
|
officialDragMoved = false
|
|
@@ -1826,8 +1954,22 @@ window.__ModuleLoader__.load({
|
|
|
1826
1954
|
// ConversationWidthControls 同款的 ResizeObserver 观察同一节点,把覆盖值/
|
|
1827
1955
|
// 官方键/官方变量的收敛压到同一批回调内,开合过程不再出现滞留窄值的中间态。
|
|
1828
1956
|
let watchedColumn = null
|
|
1829
|
-
|
|
1957
|
+
let watchedColumnWidth = -1
|
|
1958
|
+
let watchedContentWidth = -1
|
|
1959
|
+
const columnObserver = new ResizeObserver((entries) => {
|
|
1830
1960
|
if (disposed) return
|
|
1961
|
+
// 块级容器的高度会随流式输出、折叠动画变化;宽度没变时这些回调与
|
|
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
|
+
}
|
|
1969
|
+
const current = widthTargets()
|
|
1970
|
+
const width = current ? current.column : 0
|
|
1971
|
+
if (width === watchedColumnWidth) return
|
|
1972
|
+
watchedColumnWidth = width
|
|
1831
1973
|
adoptOfficialWidthOnStart()
|
|
1832
1974
|
normalizeWidthDrift()
|
|
1833
1975
|
if (state.widthPx != null && !widthTargets()) scheduleWidthTargetRetry()
|
|
@@ -1840,6 +1982,8 @@ window.__ModuleLoader__.load({
|
|
|
1840
1982
|
if (container === watchedColumn) return
|
|
1841
1983
|
columnObserver.disconnect()
|
|
1842
1984
|
watchedColumn = container
|
|
1985
|
+
watchedColumnWidth = -1
|
|
1986
|
+
watchedContentWidth = -1
|
|
1843
1987
|
if (container) columnObserver.observe(container)
|
|
1844
1988
|
}
|
|
1845
1989
|
|
|
@@ -2141,13 +2285,23 @@ window.__ModuleLoader__.load({
|
|
|
2141
2285
|
}
|
|
2142
2286
|
return walk(root)
|
|
2143
2287
|
}
|
|
2288
|
+
function texMarkdown(tex, display) {
|
|
2289
|
+
return (display ? '$$' : '$') + tex + (display ? '$$' : '$')
|
|
2290
|
+
}
|
|
2144
2291
|
let turndownSvc = null
|
|
2145
|
-
/** 懒建并复用 turndown
|
|
2146
|
-
* .katex / .katex-display 由 KaTeX 的 annotation 源码出口还原)。 */
|
|
2292
|
+
/** 懒建并复用 turndown 实例(标准库负责标签映射;补公式还原与表格单元格管道符转义)。 */
|
|
2147
2293
|
function getTurndown() {
|
|
2148
2294
|
if (turndownSvc) return turndownSvc
|
|
2149
2295
|
turndownSvc = new TurndownService({ codeBlockStyle: 'fenced', headingStyle: 'atx', bulletListMarker: '-' })
|
|
2150
2296
|
turndownSvc.use(turndownPluginGfm.gfm) // GFM 扩展:表格 / 删除线 / 任务列表(官方配套插件,见文件尾 vendored 段)
|
|
2297
|
+
// vendored gfm 的单元格规则不转义 |,含管道符的单元格复制后会断列;在这里覆盖。
|
|
2298
|
+
turndownSvc.addRule('tableCellPipes', {
|
|
2299
|
+
filter: ['th', 'td'],
|
|
2300
|
+
replacement: (content, node) => {
|
|
2301
|
+
const prefix = node.cellIndex === 0 ? '| ' : ' '
|
|
2302
|
+
return prefix + content.replace(/\|/g, '\\|') + ' |'
|
|
2303
|
+
},
|
|
2304
|
+
})
|
|
2151
2305
|
turndownSvc.addRule('katex', {
|
|
2152
2306
|
filter: (node) => {
|
|
2153
2307
|
const cls = String((node && node.className) || '')
|
|
@@ -2156,7 +2310,8 @@ window.__ModuleLoader__.load({
|
|
|
2156
2310
|
replacement: (_content, node) => {
|
|
2157
2311
|
const tex = findTexSource(node)
|
|
2158
2312
|
if (!tex) return ''
|
|
2159
|
-
|
|
2313
|
+
const display = String(node.className).indexOf('katex-display') !== -1
|
|
2314
|
+
return display ? '\n' + texMarkdown(tex, true) + '\n' : texMarkdown(tex, false)
|
|
2160
2315
|
},
|
|
2161
2316
|
})
|
|
2162
2317
|
return turndownSvc
|
|
@@ -2185,30 +2340,317 @@ window.__ModuleLoader__.load({
|
|
|
2185
2340
|
}
|
|
2186
2341
|
return out.join('\n').trim()
|
|
2187
2342
|
}
|
|
2343
|
+
/** 向上找选区端点最近的匹配元素。 */
|
|
2344
|
+
function closestFrom(node, selector, elOf) {
|
|
2345
|
+
const el = elOf(node)
|
|
2346
|
+
return el && el.closest ? el.closest(selector) : null
|
|
2347
|
+
}
|
|
2348
|
+
/** 选区所在的块级上下文:引用层数与最近的标题级别。 */
|
|
2349
|
+
function blockContext(node) {
|
|
2350
|
+
let quotes = 0
|
|
2351
|
+
let heading = 0
|
|
2352
|
+
let el = node && (node.nodeType === 1 ? node : node.parentElement)
|
|
2353
|
+
while (el && !el.matches('[data-chat-node-key]')) {
|
|
2354
|
+
if (el.tagName === 'BLOCKQUOTE') quotes += 1
|
|
2355
|
+
else if (!heading) {
|
|
2356
|
+
const level = /^H([1-6])$/.exec(el.tagName)
|
|
2357
|
+
if (level) heading = Number(level[1])
|
|
2358
|
+
}
|
|
2359
|
+
el = el.parentElement
|
|
2360
|
+
}
|
|
2361
|
+
return { quotes, heading }
|
|
2362
|
+
}
|
|
2363
|
+
/** 逐行补回引用前缀(嵌套引用逐层叠加)。 */
|
|
2364
|
+
function quoteMarkdown(md, depth) {
|
|
2365
|
+
if (!depth) return md
|
|
2366
|
+
const line = '> '.repeat(depth)
|
|
2367
|
+
const blank = line.trimEnd()
|
|
2368
|
+
return md.split('\n').map((text) => (text ? line + text : blank)).join('\n')
|
|
2369
|
+
}
|
|
2370
|
+
function cellAlign(cell) {
|
|
2371
|
+
const valid = (value) => value === 'left' || value === 'right' || value === 'center'
|
|
2372
|
+
const explicit = (cell.getAttribute('align') || '').toLowerCase()
|
|
2373
|
+
if (valid(explicit)) return explicit
|
|
2374
|
+
const inline = cell.style && cell.style.textAlign ? cell.style.textAlign.toLowerCase() : ''
|
|
2375
|
+
if (valid(inline)) return inline
|
|
2376
|
+
const cls = String(cell.className || '')
|
|
2377
|
+
const named = /(?:^|\s)(?:align-|text-align-|md-align-|text-)(left|center|right)(?:\s|$)/.exec(cls)
|
|
2378
|
+
return named ? named[1] : ''
|
|
2379
|
+
}
|
|
2380
|
+
/** 只有 DSH 实际渲染出的 GFM 表(唯一表头、等宽行、行内单元格)才可还原。 */
|
|
2381
|
+
function gfmTableParts(table) {
|
|
2382
|
+
const head = table.tHead
|
|
2383
|
+
if (!head || head.rows.length !== 1 || table.tFoot || table.caption) return null
|
|
2384
|
+
const header = head.rows[0]
|
|
2385
|
+
const headerCells = [...header.cells]
|
|
2386
|
+
const width = headerCells.length
|
|
2387
|
+
if (!width || headerCells.some((cell) => cell.tagName !== 'TH')) return null
|
|
2388
|
+
// 单元格只能放行内内容:块结构、硬换行、独立公式都会破坏 GFM 行结构。
|
|
2389
|
+
const cellFits = (cell) =>
|
|
2390
|
+
cell.rowSpan === 1 && cell.colSpan === 1 &&
|
|
2391
|
+
!cell.querySelector('table, ul, ol, pre, blockquote, p, div, h1, h2, h3, h4, h5, h6, hr, br, .katex-display')
|
|
2392
|
+
const rowFits = (row) =>
|
|
2393
|
+
row.cells.length === width && [...row.cells].every(cellFits)
|
|
2394
|
+
const rows = [...table.rows]
|
|
2395
|
+
if (rows[0] !== header || !rows.every(rowFits)) return null
|
|
2396
|
+
return { header, headerCells, rows }
|
|
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
|
+
}
|
|
2419
|
+
/** 表格:按显示结构还原 GFM,只补被选中的整行与原表头;非 GFM 结构交还原生复制。 */
|
|
2420
|
+
function tableReplacement(range, elOf) {
|
|
2421
|
+
const startTable = closestFrom(range.startContainer, 'table', elOf)
|
|
2422
|
+
const endTable = closestFrom(range.endContainer, 'table', elOf)
|
|
2423
|
+
if (!startTable && !endTable) return null
|
|
2424
|
+
if (!startTable || !endTable || startTable !== endTable) return null // 跨表/混合选区走通用转换,不能整段退回原生
|
|
2425
|
+
const startCell = closestFrom(range.startContainer, 'td,th', elOf)
|
|
2426
|
+
const endCell = closestFrom(range.endContainer, 'td,th', elOf)
|
|
2427
|
+
if (!startCell || !endCell) return null
|
|
2428
|
+
// 单个单元格内部按普通行内内容复制,不扩成整表。
|
|
2429
|
+
if (startCell === endCell) return null
|
|
2430
|
+
const startRow = startCell.closest('tr')
|
|
2431
|
+
const endRow = endCell.closest('tr')
|
|
2432
|
+
if (!startRow || !endRow) return false
|
|
2433
|
+
const parts = gfmTableParts(startTable)
|
|
2434
|
+
if (!parts) return false
|
|
2435
|
+
const rows = parts.rows
|
|
2436
|
+
const si = rows.indexOf(startRow)
|
|
2437
|
+
const ei = rows.indexOf(endRow)
|
|
2438
|
+
if (si < 0 || ei < 0) return false
|
|
2439
|
+
const box = document.createElement('div')
|
|
2440
|
+
box.append(buildGfmTable(parts, Math.min(si, ei), Math.max(si, ei)))
|
|
2441
|
+
return box
|
|
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
|
+
}
|
|
2470
|
+
/** 列表:跨多个列表项时补齐完整项与 ol 起始序号;单项内部保持普通文本。 */
|
|
2471
|
+
function listReplacement(range, elOf) {
|
|
2472
|
+
const startItem = closestFrom(range.startContainer, 'li', elOf)
|
|
2473
|
+
const endItem = closestFrom(range.endContainer, 'li', elOf)
|
|
2474
|
+
if (!startItem && !endItem) return null
|
|
2475
|
+
if (!startItem || !endItem) return null // 代码块→列表等混合选区仍应保留 Markdown
|
|
2476
|
+
const startList = startItem.parentElement
|
|
2477
|
+
const endList = endItem.parentElement
|
|
2478
|
+
if (!startList || startList !== endList || !/^(UL|OL)$/.test(startList.tagName)) return null
|
|
2479
|
+
if (startItem === endItem) return null
|
|
2480
|
+
const items = [...startList.children].filter((child) => child.tagName === 'LI')
|
|
2481
|
+
const si = items.indexOf(startItem)
|
|
2482
|
+
const ei = items.indexOf(endItem)
|
|
2483
|
+
if (si < 0 || ei < 0) return false
|
|
2484
|
+
const lo = Math.min(si, ei)
|
|
2485
|
+
const hi = Math.max(si, ei)
|
|
2486
|
+
const ordered = startList.tagName === 'OL'
|
|
2487
|
+
const list = document.createElement(ordered ? 'ol' : 'ul')
|
|
2488
|
+
if (ordered) {
|
|
2489
|
+
const start = Number.parseInt(startList.getAttribute('start') || '1', 10)
|
|
2490
|
+
const first = lo + (Number.isFinite(start) ? start : 1)
|
|
2491
|
+
if (first !== 1) list.setAttribute('start', String(first))
|
|
2492
|
+
}
|
|
2493
|
+
for (const item of items.slice(lo, hi + 1)) list.append(item.cloneNode(true))
|
|
2494
|
+
const box = document.createElement('div')
|
|
2495
|
+
box.append(list)
|
|
2496
|
+
return box
|
|
2497
|
+
}
|
|
2498
|
+
function structuralReplacement(range, elOf) {
|
|
2499
|
+
const table = tableReplacement(range, elOf)
|
|
2500
|
+
if (table === false) return false
|
|
2501
|
+
if (table) return table
|
|
2502
|
+
return listReplacement(range, elOf)
|
|
2503
|
+
}
|
|
2504
|
+
/** DSH 的代码语言只写在横幅里(pre/code 上没有 language-* 类)。 */
|
|
2505
|
+
function dshCodeLanguage(pre) {
|
|
2506
|
+
const content = pre.closest('[data-code-block-content]')
|
|
2507
|
+
const block = content ? content.parentElement : pre.closest('.md-code-block')
|
|
2508
|
+
const banner = block ? block.querySelector('[data-code-block-banner]') : null
|
|
2509
|
+
if (!banner) return ''
|
|
2510
|
+
const info = banner.querySelector('[data-text-interaction-exclude]')
|
|
2511
|
+
const span = banner.querySelector('span[class*="language"]') || banner.querySelector('span')
|
|
2512
|
+
const raw = (info ? info.textContent : span ? span.textContent : '').trim()
|
|
2513
|
+
// 无语言时官方横幅显示本地化的“代码/Code”,不能当围栏语言。
|
|
2514
|
+
if (!/^[A-Za-z0-9_+#.-]{1,30}$/.test(raw) || /^code$/i.test(raw)) return ''
|
|
2515
|
+
return raw
|
|
2516
|
+
}
|
|
2517
|
+
/** 把官方横幅语言补成 language-* 类(转换器只认这个),并移除横幅等非正文 UI。 */
|
|
2518
|
+
function normalizeCodeBlocks(root) {
|
|
2519
|
+
for (const pre of root.querySelectorAll('pre')) {
|
|
2520
|
+
const code = pre.firstElementChild
|
|
2521
|
+
if (!code || code.tagName !== 'CODE') continue
|
|
2522
|
+
if (/(^|\s)language-\S+/.test(code.getAttribute('class') || '')) continue
|
|
2523
|
+
const lang = pre.getAttribute('data-dsrb-lang') || dshCodeLanguage(pre)
|
|
2524
|
+
if (lang) code.className = (code.className ? code.className + ' ' : '') + 'language-' + lang
|
|
2525
|
+
}
|
|
2526
|
+
for (const banner of [...root.querySelectorAll('[data-code-block-banner]')]) banner.remove()
|
|
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
|
+
}
|
|
2611
|
+
/** 校验选区里的所有表格并把对齐落到 align;非 GFM 表返回 false。 */
|
|
2612
|
+
function normalizeGfmTables(root) {
|
|
2613
|
+
for (const table of root.querySelectorAll('table')) {
|
|
2614
|
+
const parts = gfmTableParts(table)
|
|
2615
|
+
if (!parts) return false
|
|
2616
|
+
for (const cell of parts.headerCells) {
|
|
2617
|
+
const align = cellAlign(cell)
|
|
2618
|
+
if (align) cell.setAttribute('align', align)
|
|
2619
|
+
}
|
|
2620
|
+
}
|
|
2621
|
+
return true
|
|
2622
|
+
}
|
|
2188
2623
|
function onCopy(e) {
|
|
2189
2624
|
try {
|
|
2190
2625
|
const sel = document.getSelection()
|
|
2191
2626
|
if (!sel || sel.isCollapsed || !sel.rangeCount) return
|
|
2627
|
+
const elOf = (n) => (n && n.nodeType === 3 ? n.parentElement : n)
|
|
2192
2628
|
// 选区两端都要在消息行内才接管(跨区选择保持系统默认行为)
|
|
2193
2629
|
const inChat = (n) => {
|
|
2194
|
-
const node =
|
|
2630
|
+
const node = elOf(n)
|
|
2195
2631
|
return !!(node && node.closest && node.closest('[data-chat-node-key]'))
|
|
2196
2632
|
}
|
|
2197
2633
|
if (!inChat(sel.anchorNode) || !inChat(sel.focusNode)) return
|
|
2198
2634
|
// 可编辑区域(输入框/富文本编辑器)保持原生复制
|
|
2199
2635
|
const inEditable = (n) => {
|
|
2200
|
-
const node =
|
|
2636
|
+
const node = elOf(n)
|
|
2201
2637
|
return !!(node && node.closest && node.closest('input, textarea, [contenteditable="true"], [contenteditable=""], [contenteditable="plaintext-only"]'))
|
|
2202
2638
|
}
|
|
2203
2639
|
if (inEditable(sel.anchorNode) || inEditable(sel.focusNode)) return
|
|
2204
|
-
|
|
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
|
|
2205
2647
|
const ka = elOf(sel.anchorNode).closest('.katex')
|
|
2206
2648
|
const kf = elOf(sel.focusNode).closest('.katex')
|
|
2207
2649
|
// 只选中了某个公式本身(两端落在同一个公式内)→ 直接输出该公式的 LaTeX
|
|
2208
2650
|
if (ka && ka === kf) {
|
|
2209
2651
|
const tex = findTexSource(ka)
|
|
2210
2652
|
if (tex) {
|
|
2211
|
-
e.clipboardData.setData('text/plain', ka.closest('.katex-display')
|
|
2653
|
+
e.clipboardData.setData('text/plain', texMarkdown(tex, !!ka.closest('.katex-display')))
|
|
2212
2654
|
e.clipboardData.setData('text/html', ka.outerHTML) // 富目标(Word 等)粘贴仍显示公式
|
|
2213
2655
|
e.preventDefault()
|
|
2214
2656
|
return
|
|
@@ -2226,39 +2668,63 @@ window.__ModuleLoader__.load({
|
|
|
2226
2668
|
const ke = katexAt(work.endContainer)
|
|
2227
2669
|
if (ks) work.setStartBefore(ks)
|
|
2228
2670
|
if (ke) work.setEndAfter(ke)
|
|
2229
|
-
const
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2671
|
+
const structural = structuralReplacement(work, elOf)
|
|
2672
|
+
if (structural === false) return // 不支持的结构:交还原生复制(纯文本)
|
|
2673
|
+
let box
|
|
2674
|
+
if (structural) {
|
|
2675
|
+
box = structural
|
|
2676
|
+
} else {
|
|
2677
|
+
box = document.createElement('div')
|
|
2678
|
+
// 选区克隆丢掉共同祖先往上的层级(代码块外层的 <pre> 即如此丢的),转换器的
|
|
2679
|
+
// 空白折叠随即把换行折掉;按现场回收"预格式化"祖先链(判据=实际生效的
|
|
2680
|
+
// white-space)补壳还原层级——非预格式化选区零改动。
|
|
2681
|
+
const cac = work.commonAncestorContainer
|
|
2682
|
+
let anc = cac.nodeType === 1 ? cac : cac.parentElement
|
|
2683
|
+
const ancShells = []
|
|
2684
|
+
while (anc && anc !== document.body && !anc.matches('[data-chat-node-key]')) {
|
|
2685
|
+
ancShells.push(anc)
|
|
2686
|
+
anc = anc.parentElement
|
|
2687
|
+
}
|
|
2688
|
+
let topShell = -1
|
|
2689
|
+
let inlineShell = -1
|
|
2690
|
+
const INLINE_SHELL = new Set(['CODE', 'STRONG', 'B', 'EM', 'I', 'DEL', 'S', 'STRIKE', 'A'])
|
|
2691
|
+
for (let i = 0; i < ancShells.length; i++) {
|
|
2692
|
+
const ws = getComputedStyle(ancShells[i]).whiteSpace
|
|
2693
|
+
if (ws === 'pre' || ws === 'pre-wrap' || ws === 'pre-line' || ws === 'break-spaces') topShell = i
|
|
2694
|
+
// 行内语义元素同样会在克隆时丢外壳:<code> 补 `…`,粗体/斜体/
|
|
2695
|
+
// 删除线/链接补对应 Markdown;取最外层,嵌套格式才能完整保留。
|
|
2696
|
+
if (INLINE_SHELL.has(ancShells[i].tagName)) inlineShell = i
|
|
2697
|
+
}
|
|
2698
|
+
if (topShell < 0 && inlineShell >= 0) topShell = inlineShell
|
|
2699
|
+
let nestAt = box
|
|
2700
|
+
for (let i = topShell; i >= 0; i--) {
|
|
2701
|
+
const shell = ancShells[i].cloneNode(false)
|
|
2702
|
+
if (shell.nodeName === 'PRE') {
|
|
2703
|
+
const lang = dshCodeLanguage(ancShells[i])
|
|
2704
|
+
if (lang) shell.setAttribute('data-dsrb-lang', lang)
|
|
2705
|
+
}
|
|
2706
|
+
nestAt.append(shell)
|
|
2707
|
+
nestAt = shell
|
|
2708
|
+
}
|
|
2709
|
+
nestAt.append(work.cloneContents())
|
|
2256
2710
|
}
|
|
2257
|
-
|
|
2711
|
+
tagSelectedCodeLanguages(box, work, elOf)
|
|
2712
|
+
completeBoundaryTables(box, work, elOf)
|
|
2713
|
+
normalizeCodeBlocks(box)
|
|
2714
|
+
fixOrderedListStarts(box, work, elOf)
|
|
2715
|
+
normalizeEmptyListItems(box)
|
|
2716
|
+
tagSelectedTaskItems(box, work, elOf)
|
|
2717
|
+
// 通用/列表结构里若夹带非 GFM 表格(含整表被框进选区的情况),
|
|
2718
|
+
// 不能让 Turndown 退回 HTML 表或拼出坏表——整段交还原生复制。
|
|
2719
|
+
if (!normalizeGfmTables(box)) return
|
|
2258
2720
|
// 公式场景先留存原始 HTML(含 <math>):粘进 Word 等富目标仍可显示公式
|
|
2259
2721
|
const html = box.querySelector('.katex') ? box.innerHTML : null
|
|
2260
|
-
|
|
2722
|
+
let md = tidyMarkdown(getTurndown().turndown(box))
|
|
2261
2723
|
if (!md) return
|
|
2724
|
+
// 克隆只带走被选中的子节点,会丢掉选区所在的引用/标题外壳;按现场补回块级语义。
|
|
2725
|
+
const context = blockContext(work.commonAncestorContainer)
|
|
2726
|
+
if (context.heading && !md.includes('\n')) md = '#'.repeat(context.heading) + ' ' + md
|
|
2727
|
+
md = quoteMarkdown(md, context.quotes)
|
|
2262
2728
|
e.clipboardData.setData('text/plain', md)
|
|
2263
2729
|
if (html) e.clipboardData.setData('text/html', html)
|
|
2264
2730
|
e.preventDefault()
|
|
@@ -2298,8 +2764,8 @@ window.__ModuleLoader__.load({
|
|
|
2298
2764
|
// 宽度目标只随事件变化:启动、面板打开、窗口尺寸、会话切换、官方手柄操作。
|
|
2299
2765
|
refreshWidthTargets()
|
|
2300
2766
|
|
|
2301
|
-
//
|
|
2302
|
-
writeState({
|
|
2767
|
+
// 启动版本标记:供诊断确认插件已加载(不影响功能)
|
|
2768
|
+
writeState({ lastBootVersion: VERSION })
|
|
2303
2769
|
|
|
2304
2770
|
// ── 卸载清理(React 槽位卸载 / 插件热重载 / ctx.effect 三路都会触发;
|
|
2305
2771
|
// 幂等,可重复调用) ──
|
|
@@ -2308,6 +2774,8 @@ window.__ModuleLoader__.load({
|
|
|
2308
2774
|
disposed = true
|
|
2309
2775
|
overlayAlive = false
|
|
2310
2776
|
ui = null
|
|
2777
|
+
if (shortcutActions === shortcutHandlers) shortcutActions = null
|
|
2778
|
+
if (shortcutHintNodes === shortcutHintMap) shortcutHintNodes = null
|
|
2311
2779
|
if (widthRefreshTimer) { clearTimeout(widthRefreshTimer); widthRefreshTimer = null }
|
|
2312
2780
|
if (widthTargetRetry) { clearTimeout(widthTargetRetry); widthTargetRetry = null }
|
|
2313
2781
|
cancelFontRetry()
|
|
@@ -2397,7 +2865,7 @@ window.__ModuleLoader__.load({
|
|
|
2397
2865
|
}
|
|
2398
2866
|
|
|
2399
2867
|
// ══ vendored: turndown start ══
|
|
2400
|
-
// turndown 7.2.
|
|
2868
|
+
// turndown 7.2.5(MIT License)——逐字内联自官方发行版 dist/turndown.js。
|
|
2401
2869
|
// 用途:选区复制时把渲染后的 HTML 按社区标准规则映射回 Markdown(不再是手写标签规则)。
|
|
2402
2870
|
// 官方仓库:https://github.com/mixmark-io/turndown
|
|
2403
2871
|
/*
|
|
@@ -3139,8 +3607,8 @@ var TurndownService = (function () {
|
|
|
3139
3607
|
* Reduces a DOM node down to its Markdown string equivalent
|
|
3140
3608
|
* @private
|
|
3141
3609
|
* @param {HTMLElement} parentNode The node to convert
|
|
3142
|
-
* @returns
|
|
3143
|
-
* @type
|
|
3610
|
+
* @returns Accumulator with Markdown representation parts of the node
|
|
3611
|
+
* @type Array
|
|
3144
3612
|
*/
|
|
3145
3613
|
|
|
3146
3614
|
function process(parentNode) {
|
|
@@ -3153,14 +3621,15 @@ var TurndownService = (function () {
|
|
|
3153
3621
|
} else if (node.nodeType === 1) {
|
|
3154
3622
|
replacement = replacementForNode.call(self, node);
|
|
3155
3623
|
}
|
|
3156
|
-
|
|
3157
|
-
|
|
3624
|
+
join(output, replacement);
|
|
3625
|
+
return output;
|
|
3626
|
+
}, []);
|
|
3158
3627
|
}
|
|
3159
3628
|
|
|
3160
3629
|
/**
|
|
3161
3630
|
* Appends strings as each rule requires and trims the output
|
|
3162
3631
|
* @private
|
|
3163
|
-
* @param {
|
|
3632
|
+
* @param {Array} output The conversion output accumulator
|
|
3164
3633
|
* @returns A trimmed version of the ouput
|
|
3165
3634
|
* @type String
|
|
3166
3635
|
*/
|
|
@@ -3169,10 +3638,10 @@ var TurndownService = (function () {
|
|
|
3169
3638
|
var self = this;
|
|
3170
3639
|
this.rules.forEach(function (rule) {
|
|
3171
3640
|
if (typeof rule.append === 'function') {
|
|
3172
|
-
|
|
3641
|
+
join(output, rule.append(self.options));
|
|
3173
3642
|
}
|
|
3174
3643
|
});
|
|
3175
|
-
return output.replace(/^[\t\r\n]+/, '').replace(/[\t\r\n\s]+$/, '');
|
|
3644
|
+
return output.join('').replace(/^[\t\r\n]+/, '').replace(/[\t\r\n\s]+$/, '');
|
|
3176
3645
|
}
|
|
3177
3646
|
|
|
3178
3647
|
/**
|
|
@@ -3185,27 +3654,29 @@ var TurndownService = (function () {
|
|
|
3185
3654
|
|
|
3186
3655
|
function replacementForNode(node) {
|
|
3187
3656
|
var rule = this.rules.forNode(node);
|
|
3188
|
-
var content = process.call(this, node);
|
|
3657
|
+
var content = process.call(this, node).join('');
|
|
3189
3658
|
var whitespace = node.flankingWhitespace;
|
|
3190
3659
|
if (whitespace.leading || whitespace.trailing) content = content.trim();
|
|
3191
3660
|
return whitespace.leading + rule.replacement(content, node, this.options) + whitespace.trailing;
|
|
3192
3661
|
}
|
|
3193
3662
|
|
|
3194
3663
|
/**
|
|
3195
|
-
* Joins replacement
|
|
3664
|
+
* Joins replacement with the current output accumulator using the appropriate number of new lines
|
|
3665
|
+
* (makes sure the only trailing whitespace will be in the last array element)
|
|
3196
3666
|
* @private
|
|
3197
|
-
* @param {String} output The current conversion output
|
|
3667
|
+
* @param {String} output The current conversion output accumulator
|
|
3198
3668
|
* @param {String} replacement The string to append to the output
|
|
3199
|
-
* @returns Joined output
|
|
3200
|
-
* @type String
|
|
3201
3669
|
*/
|
|
3202
3670
|
|
|
3203
3671
|
function join(output, replacement) {
|
|
3204
|
-
var
|
|
3672
|
+
var tail = output.pop() || '';
|
|
3673
|
+
var s1 = trimTrailingNewlines(tail);
|
|
3205
3674
|
var s2 = trimLeadingNewlines(replacement);
|
|
3206
|
-
var nls = Math.max(
|
|
3675
|
+
var nls = Math.max(tail.length - s1.length, replacement.length - s2.length);
|
|
3207
3676
|
var separator = '\n\n'.substring(0, nls);
|
|
3208
|
-
|
|
3677
|
+
if (s1) output.push(s1);
|
|
3678
|
+
if (separator) output.push(separator);
|
|
3679
|
+
if (s2) output.push(s2);
|
|
3209
3680
|
}
|
|
3210
3681
|
|
|
3211
3682
|
/**
|
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",
|