dsh-happy-reader 0.0.0-stage → 0.2.1
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/LICENSE +21 -0
- package/README.md +124 -2
- package/cordis.patch.yml +9 -0
- package/lib/client.js +2480 -0
- package/lib/index.js +12 -0
- package/package.json +44 -4
package/lib/client.js
ADDED
|
@@ -0,0 +1,2480 @@
|
|
|
1
|
+
// dsh-happy-reader(Happy Reader)—— 浏览器半边
|
|
2
|
+
//
|
|
3
|
+
// 九项功能:
|
|
4
|
+
// ① 隐藏输入区(composer)
|
|
5
|
+
// ② 隐藏上边栏(会话标题栏;最上方的系统窗口标题条是系统窗口控制区,未触碰)
|
|
6
|
+
// ③ 字号 10–40px(10–22 与官方设置直连,超出部分用缩放层补齐)
|
|
7
|
+
// ④ 内容宽度(官方范围内与官方手柄同键同步;超出官方钳制上限时扩展,拖手柄即交还)
|
|
8
|
+
// ⑤ 字体选择(正文字体·中文/西文分设 + 代码字体,只列出本机已装字体)
|
|
9
|
+
// ⑥ 全屏(网页全屏 API;Esc 或再点开关退出,状态与真实全屏双向同步)
|
|
10
|
+
// ⑦ 行距 / 字距(面板最下方;1.0× / 0px 即官方原样)
|
|
11
|
+
// ⑧ 面板可拖动 / 可拉拽高度(双击标题栏复位为自动布局;位置与高度都会记住)
|
|
12
|
+
// ⑨ 复制为 Markdown(选中消息内容复制时,公式还原为 LaTeX 源码、代码转围栏等)
|
|
13
|
+
//
|
|
14
|
+
// 设计要点(细节见各段代码注释):
|
|
15
|
+
// · 隐藏输入区必须用 height:0 而非 display:none —— 官方滚动锚定依赖该元素几何
|
|
16
|
+
// · <style> 的 disabled 一律在挂载后设置(未挂入文档时设置会被静默丢弃,CSSOM 行为)
|
|
17
|
+
// · 中英分字用 @font-face(local()+unicode-range) 按区间硬分区;三处字体全"默认"时覆盖层整表停用
|
|
18
|
+
// · 字体覆盖层依赖官方主题排印 token,主题可能稍后才就绪 → 轻量定时器稍后补建
|
|
19
|
+
// · 热重载:DSH 监视插件文件变化会再次执行本客户端,重建前须先拆旧实例(见 apply 开头)
|
|
20
|
+
// · 选区复制(功能⑨):公式还原读 KaTeX 的 annotation 源码出口(与官方 copy-tex 同源);
|
|
21
|
+
// 标签→Markdown 映射由 vendored turndown + 官方 gfm 插件承担(均 MIT,逐字内联于文件尾)
|
|
22
|
+
//
|
|
23
|
+
// 本文件采用官方客户端插件包格式:外壳加载时全局 __ModuleLoader__ 已就绪,
|
|
24
|
+
// 登记 { id: 包名, factory };factory 在插件挂载时执行,导出的 apply 即插件本体。
|
|
25
|
+
window.__ModuleLoader__.load({
|
|
26
|
+
id: 'dsh-happy-reader',
|
|
27
|
+
factory: (require) => {
|
|
28
|
+
var module = { exports: {} }
|
|
29
|
+
var exports = module.exports
|
|
30
|
+
|
|
31
|
+
// ── 常量 ────────────────────────────────────────────────────────────
|
|
32
|
+
const VERSION = '0.2.1'
|
|
33
|
+
const STATE_KEY = 'dsh.reader-boost.v1'
|
|
34
|
+
const FAB_ID = 'dsrb-fab'
|
|
35
|
+
const PANEL_ID = 'dsrb-panel'
|
|
36
|
+
const STYLE_ID = 'dsrb-style'
|
|
37
|
+
const HIDE_STYLE_ID = 'dsrb-hide-composer'
|
|
38
|
+
const HIDE_TOP_STYLE_ID = 'dsrb-hide-top'
|
|
39
|
+
const FONT_STYLE_ID = 'dsrb-font-scale'
|
|
40
|
+
const FONT_PICK_STYLE_ID = 'dsrb-font-pick'
|
|
41
|
+
const FS_STYLE_ID = 'dsrb-fullscreen'
|
|
42
|
+
const FAB_SIZE = 40
|
|
43
|
+
const PANEL_WIDTH = 280
|
|
44
|
+
const PANEL_MAX_H = 480 // 面板高度锁:新增选项不再增高面板,超出部分由内部滚动查看
|
|
45
|
+
const PANEL_MIN_H = 180 // 面板拉拽高度的下限
|
|
46
|
+
const BORDER_Y = 2 // 面板上下边框各 1px(外框高 = 内容高 + 2,定位预算统一用它)
|
|
47
|
+
|
|
48
|
+
// 官方宽度机制的同一套约定(对照 dsh-client-ui-conversation # ConversationWidthControls)
|
|
49
|
+
const WIDTH_PREF_KEY = 'dsh.conversation.contentWidth'
|
|
50
|
+
const CONTENT_MIN = 640
|
|
51
|
+
const CONTENT_EDGE_BUDGET = 176
|
|
52
|
+
|
|
53
|
+
// 官方字号范围(theme.setFontSize 只接受 10–22 的整数,超出部分由缩放层补齐)
|
|
54
|
+
const FONT_MIN = 10
|
|
55
|
+
const FONT_MAX = 40
|
|
56
|
+
const FONT_OFFICIAL_MAX = 22
|
|
57
|
+
|
|
58
|
+
// 行距倍率与字距范围(0.1 步进;边界值 = 完全还原官方排版)
|
|
59
|
+
const LINEH_MIN = 1
|
|
60
|
+
const LINEH_MAX = 2.2
|
|
61
|
+
const LETTERSP_MIN = -1
|
|
62
|
+
const LETTERSP_MAX = 3
|
|
63
|
+
|
|
64
|
+
// 官方字号变量(dsh-client-ui-theme 提供、ui-layout 写入 body 内联样式)
|
|
65
|
+
const CONTENT_FONT_SIZE_VARIABLE = '--dsh-content-font-size'
|
|
66
|
+
|
|
67
|
+
// ── 样式 ────────────────────────────────────────────────────────────
|
|
68
|
+
const CSS = `
|
|
69
|
+
.dsrb-fab{position:fixed;z-index:9999;width:40px;height:40px;border-radius:12px;
|
|
70
|
+
border:1px solid rgba(128,128,128,.35);background:rgba(28,28,30,.85);color:#f5f5f5;
|
|
71
|
+
font:600 15px/1 system-ui,-apple-system,"Segoe UI",sans-serif;display:flex;align-items:center;
|
|
72
|
+
justify-content:center;cursor:grab;user-select:none;-webkit-user-select:none;
|
|
73
|
+
box-shadow:0 4px 14px rgba(0,0,0,.28);backdrop-filter:blur(6px)}
|
|
74
|
+
.dsrb-fab:active{cursor:grabbing}
|
|
75
|
+
.dsrb-panel{position:fixed;z-index:9999;width:${PANEL_WIDTH}px;padding:0 14px;border-radius:12px;
|
|
76
|
+
max-height:calc(100vh - 18px);display:flex;flex-direction:column;overflow:hidden;
|
|
77
|
+
border:1px solid rgba(128,128,128,.3);background:rgba(28,28,30,.96);color:#ececec;
|
|
78
|
+
font:13px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif;
|
|
79
|
+
box-shadow:0 8px 28px rgba(0,0,0,.38);backdrop-filter:blur(8px)}
|
|
80
|
+
.dsrb-body{flex:1 1 auto;min-height:0;overflow-y:auto;overscroll-behavior:contain;padding:12px 0}
|
|
81
|
+
.dsrb-body::-webkit-scrollbar{width:8px}
|
|
82
|
+
.dsrb-body::-webkit-scrollbar-thumb{background:rgba(128,128,128,0);border-radius:4px;transition:background .15s}
|
|
83
|
+
.dsrb-body:hover::-webkit-scrollbar-thumb{background:rgba(128,128,128,.45)}
|
|
84
|
+
.dsrb-body:hover::-webkit-scrollbar-thumb:hover{background:rgba(128,128,128,.65)}
|
|
85
|
+
.dsrb-body::-webkit-scrollbar-track{background:transparent}
|
|
86
|
+
.dsrb-head{display:flex;align-items:center;justify-content:space-between;margin:0 0 4px;cursor:grab}
|
|
87
|
+
.dsrb-head:active{cursor:grabbing}
|
|
88
|
+
.dsrb-resize{flex:none;height:10px;cursor:ns-resize;border-radius:0 0 12px 12px}
|
|
89
|
+
.dsrb-resize-top{flex:none;height:10px;cursor:ns-resize;border-radius:12px 12px 0 0}
|
|
90
|
+
.dsrb-resize:hover,.dsrb-resize-top:hover{background:rgba(255,255,255,.07)}
|
|
91
|
+
.dsrb-head h4{margin:0;font-size:13px;font-weight:600}
|
|
92
|
+
.dsrb-headHint{font-size:11.5px;color:#8d8d8d;margin-left:auto;margin-right:8px;white-space:nowrap}
|
|
93
|
+
.dsrb-x{width:22px;height:22px;padding:0;border-radius:6px;border:1px solid rgba(128,128,128,.3);
|
|
94
|
+
background:rgba(255,255,255,.06);color:#ddd;font-size:13px;line-height:1;cursor:pointer}
|
|
95
|
+
.dsrb-x:hover{background:rgba(255,255,255,.16)}
|
|
96
|
+
.dsrb-row{display:flex;align-items:flex-start;gap:10px;padding:8px 0;
|
|
97
|
+
border-top:1px solid rgba(128,128,128,.14)}
|
|
98
|
+
.dsrb-head+.dsrb-row{border-top:0}
|
|
99
|
+
.dsrb-row-click{cursor:pointer}
|
|
100
|
+
.dsrb-row-click:hover{background:rgba(255,255,255,.045)}
|
|
101
|
+
.dsrb-rowMain{flex:1;min-width:0}
|
|
102
|
+
.dsrb-rowTitle{font-weight:600}
|
|
103
|
+
.dsrb-rowTitle b{color:#fff;font-weight:600}
|
|
104
|
+
.dsrb-rowSub{color:#9a9a9a;font-size:12px;margin-top:2px}
|
|
105
|
+
.dsrb-switch{position:relative;flex:none;width:34px;height:20px;margin-top:1px;cursor:pointer}
|
|
106
|
+
.dsrb-switch input{position:absolute;inset:0;opacity:0;margin:0;pointer-events:none}
|
|
107
|
+
.dsrb-switch span{position:absolute;inset:0;border-radius:10px;background:rgba(120,120,120,.35);
|
|
108
|
+
transition:background .15s;pointer-events:none}
|
|
109
|
+
.dsrb-switch span:after{content:"";position:absolute;top:2px;left:2px;width:16px;height:16px;
|
|
110
|
+
border-radius:50%;background:#fff;transition:transform .15s}
|
|
111
|
+
.dsrb-switch input:checked+span{background:#2f7af0}
|
|
112
|
+
.dsrb-switch input:checked+span:after{transform:translateX(14px)}
|
|
113
|
+
.dsrb-stepper{flex:none;display:flex;gap:6px;align-items:center}
|
|
114
|
+
.dsrb-stepper button{width:28px;height:28px;border-radius:8px;border:1px solid rgba(128,128,128,.3);
|
|
115
|
+
background:rgba(255,255,255,.06);color:#eee;font-size:14px;line-height:1;cursor:pointer}
|
|
116
|
+
.dsrb-stepper button:hover{background:rgba(255,255,255,.14)}
|
|
117
|
+
.dsrb-stepper button:disabled{opacity:.45;cursor:default}
|
|
118
|
+
.dsrb-widthRow{display:flex;align-items:center;gap:8px;margin-top:7px}
|
|
119
|
+
.dsrb-widthRow input[type=range]{flex:1;min-width:0}
|
|
120
|
+
.dsrb-widthRow button{flex:none;height:24px;padding:0 10px;border-radius:8px;
|
|
121
|
+
border:1px solid rgba(128,128,128,.3);background:rgba(255,255,255,.06);color:#eee;
|
|
122
|
+
font-size:12px;cursor:pointer}
|
|
123
|
+
.dsrb-widthRow button:hover{background:rgba(255,255,255,.14)}
|
|
124
|
+
.dsrb-widthRow button:disabled{opacity:.45;cursor:default}
|
|
125
|
+
.dsrb-select{width:100%;margin-top:7px;height:26px;padding:0 6px;border-radius:8px;
|
|
126
|
+
border:1px solid rgba(128,128,128,.3);background:rgba(255,255,255,.06);color:#eee;
|
|
127
|
+
font-size:12px;cursor:pointer}
|
|
128
|
+
.dsrb-select:focus{outline:none;border-color:#2f7af0}
|
|
129
|
+
.dsrb-select option{background:#1c1c1e;color:#eee}
|
|
130
|
+
.dsrb-pair{display:flex;gap:8px;margin-top:7px}
|
|
131
|
+
.dsrb-pairCol{flex:1;min-width:0}
|
|
132
|
+
.dsrb-mini{font-size:11px;color:#9a9a9a;margin-bottom:3px}
|
|
133
|
+
.dsrb-pairCol .dsrb-select{margin-top:0}
|
|
134
|
+
`
|
|
135
|
+
// 隐藏输入区(关键:height:0 而非 display:none —— 官方滚动锚定依赖该元素的几何信息)
|
|
136
|
+
const HIDE_CSS = '[data-composer-seat]{height:0!important;min-height:0!important;overflow:hidden!important}'
|
|
137
|
+
// 隐藏上边栏(会话标题栏)—— 排除对话框内的头部:嵌入页覆盖层(如"查询用量")的
|
|
138
|
+
// 头部承载它唯一的返回按钮(button.back),一并隐藏会把用户锁在页面里回不去
|
|
139
|
+
const HIDE_TOP_CSS = 'header[data-window-drag]:not([role="dialog"] *){display:none!important}'
|
|
140
|
+
// 字号缩放层(!important 样式表仅在需要缩放时启用;官方内联值原样保留在其下)
|
|
141
|
+
const FONT_CSS = `body{--dsh-content-font-size:calc(var(--dsrb-font-base,14px)*var(--dsrb-font-scale,1))!important}`
|
|
142
|
+
// 全屏补偿层:不归零官方定位变量 —— --dsh-windows-titlebar-height(=40px)是
|
|
143
|
+
// "开合/新对话"悬浮按钮与"应用/编辑"菜单的定位基准,归零会裁掉它们的上半截。
|
|
144
|
+
// 改为逐项覆盖:frame 顶部预留条清零、侧栏列自留该变量、收态归零内容圆角、
|
|
145
|
+
// 全屏隐藏菜单;规则由 html[data-dsrb-fullscreen] 门控,退出全屏即完全还原。
|
|
146
|
+
const FS_CSS = [
|
|
147
|
+
// 镜像官方 html[data-windows-titlebar][data-fullscreen]:overlay 顶距 20px、chrome 顶距 0
|
|
148
|
+
'html[data-windows-titlebar][data-dsrb-fullscreen]{--dsh-frame-overlay-top:20px;--dsh-frame-chrome-top:0px}',
|
|
149
|
+
// frame 的 40px 预留条清零 → 两侧列内容顶到屏幕最上缘
|
|
150
|
+
'html[data-dsrb-fullscreen] .BynINW_frame{padding-top:0!important}',
|
|
151
|
+
'html[data-dsrb-fullscreen] .BynINW_frame::before{height:0!important}',
|
|
152
|
+
// 收起态:内容列左上圆角归零(收起时圆角在 (0,0) 露出 frame 底色成为缺口)
|
|
153
|
+
'html[data-dsrb-fullscreen]:has([data-sidebar-collapsed=true]) .BynINW_centerCol{border-radius:0!important}',
|
|
154
|
+
// 展开态:侧栏列自留 40px 顶部,悬浮按钮下方内容位置与窗口态保持一致
|
|
155
|
+
'html[data-dsrb-fullscreen] .BynINW_sidebarCol{padding-top:var(--dsh-windows-titlebar-height)}',
|
|
156
|
+
// 侧栏拖拽手柄随之顶到 0(其 top 同样以 var 为基准)
|
|
157
|
+
'html[data-dsrb-fullscreen] .BynINW_handle{top:0!important}',
|
|
158
|
+
// 全屏隐藏 preload"应用/编辑"菜单(退出全屏即恢复;无键盘通道,不影响 Esc 退出)
|
|
159
|
+
'html[data-dsrb-fullscreen] [data-windows-menu]{display:none!important}'
|
|
160
|
+
].join('')
|
|
161
|
+
const FS_ATTR = 'data-dsrb-fullscreen'
|
|
162
|
+
|
|
163
|
+
// ── 状态存取 ────────────────────────────────────────────────────────
|
|
164
|
+
// state / officialDragging 挂载时由 apply() 注入,工厂层辅助函数共享
|
|
165
|
+
let state = {}
|
|
166
|
+
let officialDragging = false // 官方手柄拖动中(此期间不插手宽度)
|
|
167
|
+
function readState() {
|
|
168
|
+
try {
|
|
169
|
+
const raw = localStorage.getItem(STATE_KEY)
|
|
170
|
+
return raw ? JSON.parse(raw) : {}
|
|
171
|
+
} catch {
|
|
172
|
+
return {}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
function writeState(patch) {
|
|
176
|
+
try {
|
|
177
|
+
const next = Object.assign(readState(), patch)
|
|
178
|
+
localStorage.setItem(STATE_KEY, JSON.stringify(next))
|
|
179
|
+
} catch {
|
|
180
|
+
// 存储不可用时忽略,不影响使用
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
function clampPos(x, y, w, h) {
|
|
184
|
+
const maxX = Math.max(4, window.innerWidth - w - 4)
|
|
185
|
+
const maxY = Math.max(4, window.innerHeight - h - 4)
|
|
186
|
+
return [Math.min(Math.max(4, x), maxX), Math.min(Math.max(4, y), maxY)]
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ── 字体选择:候选表 / 本机可用性检测 / 官方排印 token 的重发 ────────
|
|
190
|
+
// 候选 = 经典常用字体;实际显示前逐个检测本机是否已装,未装的不出现。
|
|
191
|
+
// names = local() 引用的字体名(英文名优先,附中文名双保险);
|
|
192
|
+
// bold = 真粗体面的完整名(仅部分字体有,用 local() 引用真粗体面)。
|
|
193
|
+
const TEXT_FONTS_CJK = [
|
|
194
|
+
{ id: 'yahei', label: '微软雅黑', name: 'Microsoft YaHei', names: ['Microsoft YaHei', '微软雅黑'], bold: ['Microsoft YaHei Bold', 'Microsoft YaHei-Bold'] },
|
|
195
|
+
{ id: 'simsun', label: '宋体', name: 'SimSun', names: ['SimSun', '宋体'] },
|
|
196
|
+
{ id: 'simhei', label: '黑体', name: 'SimHei', names: ['SimHei', '黑体'] },
|
|
197
|
+
{ id: 'kaiti', label: '楷体', name: 'KaiTi', names: ['KaiTi', '楷体'] },
|
|
198
|
+
{ id: 'fangsong', label: '仿宋', name: 'FangSong', names: ['FangSong', '仿宋'] },
|
|
199
|
+
{ id: 'dengxian', label: '等线', name: 'DengXian', names: ['DengXian', '等线'], bold: ['DengXian Bold', 'DengXian-Bold'] },
|
|
200
|
+
{ id: 'youyuan', label: '幼圆', name: 'YouYuan', names: ['YouYuan', '幼圆'] },
|
|
201
|
+
{ id: 'stzhongsong', label: '华文中宋', name: 'STZhongsong', names: ['STZhongsong', '华文中宋'] },
|
|
202
|
+
{ id: 'stkaiti', label: '华文楷体', name: 'STKaiti', names: ['STKaiti', '华文楷体'] },
|
|
203
|
+
{ id: 'stfangsong', label: '华文仿宋', name: 'STFangsong', names: ['STFangsong', '华文仿宋'] },
|
|
204
|
+
]
|
|
205
|
+
const TEXT_FONTS_LATIN = [
|
|
206
|
+
{ id: 'georgia', label: 'Georgia', name: 'Georgia', names: ['Georgia'], bold: ['Georgia Bold', 'Georgia-Bold'] },
|
|
207
|
+
{ id: 'times', label: 'Times New Roman', name: 'Times New Roman', names: ['Times New Roman'], bold: ['Times New Roman Bold', 'Times New Roman-Bold'] },
|
|
208
|
+
{ id: 'garamond', label: 'Garamond', name: 'Garamond', names: ['Garamond'], bold: ['Garamond Bold', 'Garamond-Bold'] },
|
|
209
|
+
{ id: 'bookantiqua', label: 'Book Antiqua', name: 'Book Antiqua', names: ['Book Antiqua'], bold: ['Book Antiqua Bold', 'Book Antiqua-Bold'] },
|
|
210
|
+
{ id: 'segoe', label: 'Segoe UI', name: 'Segoe UI', names: ['Segoe UI'], bold: ['Segoe UI Bold', 'Segoe UI-Bold'] },
|
|
211
|
+
{ id: 'arial', label: 'Arial', name: 'Arial', names: ['Arial'], bold: ['Arial Bold', 'Arial-Bold'] },
|
|
212
|
+
]
|
|
213
|
+
// 中英分字区间:共享符号(引号/破折号)由西文面优先;CJK 标点/全角/假名/扩展区
|
|
214
|
+
// 归中文侧;两区间之外落回官方栈
|
|
215
|
+
const LATIN_RANGE = 'U+0020-024F, U+1E00-1EFF, U+2010-201F'
|
|
216
|
+
const CJK_RANGE =
|
|
217
|
+
'U+2010-201F, U+2026, U+2E80-2EFF, U+3000-303F, U+3040-30FF, U+3200-33FF, U+3400-4DBF, ' +
|
|
218
|
+
'U+4E00-9FFF, U+F900-FAFF, U+FE30-FE4F, U+FF00-FFEF, U+20000-2A6DF, U+2A700-2EBEF'
|
|
219
|
+
/** 生成一个虚拟字族的 @font-face 规则。
|
|
220
|
+
* 有真粗体名 → 追加 font-weight:500 900 规则(实测可拿到真粗体);
|
|
221
|
+
* 无粗体名 → 只留 400 单面(描述符不撒谎,Chromium 才会做合成粗体)。 */
|
|
222
|
+
function faceCss(family, entry, range) {
|
|
223
|
+
const src = (list) => list.map((n) => 'local("' + n + '")').join(',')
|
|
224
|
+
let out = '@font-face{font-family:"' + family + '";src:' + src(entry.names) + ';unicode-range:' + range + ';}'
|
|
225
|
+
if (entry.bold && entry.bold.length) {
|
|
226
|
+
out += '\n@font-face{font-family:"' + family + '";font-weight:500 900;src:' +
|
|
227
|
+
src(entry.bold) + ',' + src(entry.names) + ';unicode-range:' + range + ';}'
|
|
228
|
+
}
|
|
229
|
+
return out
|
|
230
|
+
}
|
|
231
|
+
const CODE_FONTS = [
|
|
232
|
+
{ id: 'consolas', label: 'Consolas', css: 'Consolas', name: 'Consolas' },
|
|
233
|
+
{ id: 'cascadia', label: 'Cascadia Code', css: '"Cascadia Code"', name: 'Cascadia Code' },
|
|
234
|
+
{ id: 'cascadiamono', label: 'Cascadia Mono', css: '"Cascadia Mono"', name: 'Cascadia Mono' },
|
|
235
|
+
{ id: 'courier', label: 'Courier New', css: '"Courier New"', name: 'Courier New' },
|
|
236
|
+
{ id: 'nsimsun', label: '新宋体', css: '"NSimSun"', name: 'NSimSun' },
|
|
237
|
+
{ id: 'firacode', label: 'Fira Code', css: '"Fira Code"', name: 'Fira Code' },
|
|
238
|
+
{ id: 'jetbrains', label: 'JetBrains Mono', css: '"JetBrains Mono"', name: 'JetBrains Mono' },
|
|
239
|
+
{ id: 'sourcecode', label: 'Source Code Pro', css: '"Source Code Pro"', name: 'Source Code Pro' },
|
|
240
|
+
]
|
|
241
|
+
let fontDetectCtx = null
|
|
242
|
+
// 字体可用性检测:canvas 字宽比对(与三套通用字族任一不同 = 本机已装)。
|
|
243
|
+
// 不用 document.fonts.check —— Chromium 里它对手写的系统字体名恒为 true。
|
|
244
|
+
function fontAvailable(name) {
|
|
245
|
+
try {
|
|
246
|
+
if (!fontDetectCtx) fontDetectCtx = document.createElement('canvas').getContext('2d')
|
|
247
|
+
const ctx = fontDetectCtx
|
|
248
|
+
if (!ctx) return true // 检测不可用时不隐藏候选
|
|
249
|
+
const TEXT = '字wmWMiIl0O8#@我'
|
|
250
|
+
const width = (font) => {
|
|
251
|
+
ctx.font = font
|
|
252
|
+
return ctx.measureText(TEXT).width
|
|
253
|
+
}
|
|
254
|
+
const quoted = '"' + name + '"'
|
|
255
|
+
return (
|
|
256
|
+
width('32px ' + quoted + ', monospace') !== width('32px monospace') ||
|
|
257
|
+
width('32px ' + quoted + ', sans-serif') !== width('32px sans-serif') ||
|
|
258
|
+
width('32px ' + quoted + ', serif') !== width('32px serif')
|
|
259
|
+
)
|
|
260
|
+
} catch {
|
|
261
|
+
return true
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
function fontChoice(list, id) {
|
|
265
|
+
if (!id) return null
|
|
266
|
+
for (const f of list) if (f.id === id) return f
|
|
267
|
+
return null
|
|
268
|
+
}
|
|
269
|
+
// 官方把整套排印 token(--dsw-font-* / --ds-font-*)定义在 :root 与 body 上。
|
|
270
|
+
// 成功解析一次后缓存;官方主题尚未就绪时返回 null(调用方靠定时器稍后重试)。
|
|
271
|
+
let themeFontDecls = null
|
|
272
|
+
function readThemeFontDecls() {
|
|
273
|
+
if (themeFontDecls) return themeFontDecls
|
|
274
|
+
const decls = new Map()
|
|
275
|
+
for (const node of document.querySelectorAll('style')) {
|
|
276
|
+
const text = node.textContent || ''
|
|
277
|
+
if (text.indexOf('--dsw-font') === -1 && text.indexOf('--ds-font') === -1) continue
|
|
278
|
+
if (node.id && node.id.indexOf('dsrb-') === 0) continue // 不扫自家样式
|
|
279
|
+
for (const m of text.matchAll(/(--d(?:sw|s)-font[a-z0-9-]+)\s*:\s*([^;{}]+)/g)) {
|
|
280
|
+
decls.set(m[1], m[2].trim())
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
if (!decls.get('--dsw-font-family') || !decls.get('--ds-font-family-code')) return null
|
|
284
|
+
themeFontDecls = decls
|
|
285
|
+
return decls
|
|
286
|
+
}
|
|
287
|
+
/** 依当前选择生成消息行作用域([data-chat-node-key])的排印覆盖 CSS。
|
|
288
|
+
* 字体全默认且未调行距/字距 或 官方主题未就绪 → null(覆盖层保持停用)。 */
|
|
289
|
+
function buildFontPickCss() {
|
|
290
|
+
const decls = readThemeFontDecls()
|
|
291
|
+
if (!decls) return null
|
|
292
|
+
const cjk = fontChoice(TEXT_FONTS_CJK, state.textFontCjk)
|
|
293
|
+
const lat = fontChoice(TEXT_FONTS_LATIN, state.textFontLatin)
|
|
294
|
+
const code = fontChoice(CODE_FONTS, state.codeFont)
|
|
295
|
+
const lineH = typeof state.lineHeight === 'number' ? state.lineHeight : null
|
|
296
|
+
const letterSp = typeof state.letterSpacing === 'number' ? state.letterSpacing : null
|
|
297
|
+
if (!cjk && !lat && !code && lineH === null && letterSp === null) return null
|
|
298
|
+
const offText = decls.get('--dsw-font-family')
|
|
299
|
+
const offCode = decls.get('--ds-font-family-code')
|
|
300
|
+
// 虚拟字族按 西文→中文 排序:引号/破折号等共享区间由西文面优先接住
|
|
301
|
+
const faces = []
|
|
302
|
+
const famNames = []
|
|
303
|
+
if (lat) { faces.push(faceCss('dsrb-text-latin', lat, LATIN_RANGE)); famNames.push('"dsrb-text-latin"') }
|
|
304
|
+
if (cjk) { faces.push(faceCss('dsrb-text-cjk', cjk, CJK_RANGE)); famNames.push('"dsrb-text-cjk"') }
|
|
305
|
+
const fam = famNames.length ? 'var(--dsrb-family)' : offText
|
|
306
|
+
const cod = code ? 'var(--dsrb-code)' : offCode
|
|
307
|
+
const parts = []
|
|
308
|
+
// 虚拟字族在前(按区间分流);官方原栈殿后兜底:区间外字符与缺字逐字回退
|
|
309
|
+
if (famNames.length) parts.push('--dsrb-family:' + famNames.join(', ') + ', ' + offText)
|
|
310
|
+
if (code) parts.push('--dsrb-code:' + code.css + ', ' + offCode)
|
|
311
|
+
if (famNames.length) parts.push('--dsw-font-family:' + fam)
|
|
312
|
+
if (code) parts.push('--ds-font-family-code:' + cod)
|
|
313
|
+
if (famNames.length) parts.push('font-family:var(--dsrb-family)') // 无自身字族的后代(如用户气泡正文)由继承拿到
|
|
314
|
+
for (const [name, value] of decls) {
|
|
315
|
+
if (name === '--dsw-font-family' || name === '--ds-font-family-code') continue
|
|
316
|
+
let next = value
|
|
317
|
+
if (famNames.length && next.indexOf('var(--dsw-font-family)') !== -1) next = next.split('var(--dsw-font-family)').join(fam)
|
|
318
|
+
if (code && next.indexOf('var(--ds-font-family-code)') !== -1) next = next.split('var(--ds-font-family-code)').join(cod)
|
|
319
|
+
// 行距:把简写里字号后的"/行高"按倍率改写(0.5px 步进),字号本身不动
|
|
320
|
+
if (lineH !== null) next = next.replace(/(\/\s*)(\d+(?:\.\d+)?)px/g, (m, lead, n) => lead + Math.round(Number(n) * lineH * 2) / 2 + 'px')
|
|
321
|
+
if (next !== value) parts.push(name + ':' + next)
|
|
322
|
+
}
|
|
323
|
+
if (letterSp !== null) parts.push('letter-spacing:' + letterSp + 'px')
|
|
324
|
+
return faces.join('\n') + '\n[data-chat-node-key]{' + parts.join(';') + '}'
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// ── 官方宽度:与 ConversationWidthControls 同一套读写 ───────────────
|
|
328
|
+
function readWidthPreference() {
|
|
329
|
+
const raw = localStorage.getItem(WIDTH_PREF_KEY)
|
|
330
|
+
if (raw === null) return null
|
|
331
|
+
const value = Number(raw)
|
|
332
|
+
return Number.isFinite(value) && value > 0 ? value : null
|
|
333
|
+
}
|
|
334
|
+
/** 官方钳制上限(列宽 − 两侧手柄的保留宽度)。 */
|
|
335
|
+
function officialMaxFor(column) {
|
|
336
|
+
return Math.max(CONTENT_MIN, Math.round(column - CONTENT_EDGE_BUDGET))
|
|
337
|
+
}
|
|
338
|
+
/** 扩展钳制上限(整个内容列宽)。 */
|
|
339
|
+
function extCapFor(column) {
|
|
340
|
+
return Math.max(CONTENT_MIN, Math.round(column))
|
|
341
|
+
}
|
|
342
|
+
/** 官方"自动"兜底公式(仅用于面板显示)。 */
|
|
343
|
+
function resolveAutoWidth(column) {
|
|
344
|
+
return Math.max(680, Math.min(column * 0.64, 920))
|
|
345
|
+
}
|
|
346
|
+
function widthTargets() {
|
|
347
|
+
const container = document.querySelector('[data-conversation-content]')
|
|
348
|
+
if (!container) return null
|
|
349
|
+
return { container, target: container.parentElement ?? container, column: container.offsetWidth }
|
|
350
|
+
}
|
|
351
|
+
/** 官方手柄拖动优先:把一个宽度状态应用到页面(state.widthPx 为准)。 */
|
|
352
|
+
function applyWidthState() {
|
|
353
|
+
const t = widthTargets()
|
|
354
|
+
if (!t) return
|
|
355
|
+
const { container, target, column } = t
|
|
356
|
+
const omax = officialMaxFor(column)
|
|
357
|
+
const cap = extCapFor(column)
|
|
358
|
+
const px = state.widthPx
|
|
359
|
+
if (px == null) {
|
|
360
|
+
localStorage.removeItem(WIDTH_PREF_KEY)
|
|
361
|
+
target.style.removeProperty('--dsh-chat-user-width')
|
|
362
|
+
container.style.removeProperty('--dsh-chat-content-width')
|
|
363
|
+
} else if (px <= omax) {
|
|
364
|
+
container.style.removeProperty('--dsh-chat-content-width')
|
|
365
|
+
localStorage.setItem(WIDTH_PREF_KEY, String(px))
|
|
366
|
+
target.style.setProperty('--dsh-chat-user-width', px + 'px')
|
|
367
|
+
} else {
|
|
368
|
+
const w = Math.min(px, cap)
|
|
369
|
+
localStorage.setItem(WIDTH_PREF_KEY, String(omax))
|
|
370
|
+
target.style.setProperty('--dsh-chat-user-width', omax + 'px')
|
|
371
|
+
container.style.setProperty('--dsh-chat-content-width', w + 'px')
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
function currentWidthInfo() {
|
|
375
|
+
const t = widthTargets()
|
|
376
|
+
if (!t) return null
|
|
377
|
+
const omax = officialMaxFor(t.column)
|
|
378
|
+
const cap = extCapFor(t.column)
|
|
379
|
+
const px = state.widthPx
|
|
380
|
+
const extActive = px != null && px > omax
|
|
381
|
+
let shown
|
|
382
|
+
if (px != null) shown = extActive ? Math.min(px, cap) : px
|
|
383
|
+
else {
|
|
384
|
+
const raw = getComputedStyle(t.container).getPropertyValue('--dsh-chat-user-width').trim()
|
|
385
|
+
shown = raw ? Number.parseFloat(raw) : resolveAutoWidth(t.column)
|
|
386
|
+
}
|
|
387
|
+
return { px: shown, auto: px == null, ext: extActive, column: t.column, min: CONTENT_MIN, max: cap }
|
|
388
|
+
}
|
|
389
|
+
/** 漂移修复:官方重发布 / 窗口变化后把我们的状态归位(官方手柄拖动中不插手)。 */
|
|
390
|
+
function normalizeWidthDrift() {
|
|
391
|
+
if (officialDragging) return
|
|
392
|
+
const t = widthTargets()
|
|
393
|
+
if (!t) return
|
|
394
|
+
const omax = officialMaxFor(t.column)
|
|
395
|
+
const cap = extCapFor(t.column)
|
|
396
|
+
const px = state.widthPx
|
|
397
|
+
if (px == null) return
|
|
398
|
+
if (px > omax) {
|
|
399
|
+
const w = Math.min(px, cap) + 'px'
|
|
400
|
+
if (t.container.style.getPropertyValue('--dsh-chat-content-width') !== w) {
|
|
401
|
+
t.container.style.setProperty('--dsh-chat-content-width', w)
|
|
402
|
+
}
|
|
403
|
+
if (localStorage.getItem(WIDTH_PREF_KEY) !== String(omax)) {
|
|
404
|
+
localStorage.setItem(WIDTH_PREF_KEY, String(omax))
|
|
405
|
+
}
|
|
406
|
+
// 官方 observer 可能先按旧键重发布残留值 —— 立即 peg 回官方上限,避免被手柄当作拖动基点
|
|
407
|
+
if (t.target.style.getPropertyValue('--dsh-chat-user-width') !== omax + 'px') {
|
|
408
|
+
t.target.style.setProperty('--dsh-chat-user-width', omax + 'px')
|
|
409
|
+
}
|
|
410
|
+
} else {
|
|
411
|
+
if (t.container.style.getPropertyValue('--dsh-chat-content-width')) {
|
|
412
|
+
t.container.style.removeProperty('--dsh-chat-content-width')
|
|
413
|
+
}
|
|
414
|
+
if (localStorage.getItem(WIDTH_PREF_KEY) !== String(px)) {
|
|
415
|
+
localStorage.setItem(WIDTH_PREF_KEY, String(px))
|
|
416
|
+
}
|
|
417
|
+
if (t.target.style.getPropertyValue('--dsh-chat-user-width') !== px + 'px') {
|
|
418
|
+
t.target.style.setProperty('--dsh-chat-user-width', px + 'px')
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// ── 字号(官方直连 + 超范围缩放层) ─────────────────────────────────
|
|
424
|
+
const fontState = { official: null }
|
|
425
|
+
let fontSvc = null
|
|
426
|
+
let fontSelfWrite = false
|
|
427
|
+
function fallbackBasePx() {
|
|
428
|
+
// 官方把该变量写在 body 内联样式上(见上方常量注释);主题服务未就绪时的兜底
|
|
429
|
+
const inline = Number.parseFloat(document.body.style.getPropertyValue(CONTENT_FONT_SIZE_VARIABLE))
|
|
430
|
+
return Number.isFinite(inline) && inline > 0 ? inline : 14
|
|
431
|
+
}
|
|
432
|
+
/** 把当前字号状态画到页面上(官方范围内 = 纯官方;超出 = 官方值 × 补齐倍率)。 */
|
|
433
|
+
function applyFont() {
|
|
434
|
+
const tag = document.getElementById(FONT_STYLE_ID)
|
|
435
|
+
if (!tag) return
|
|
436
|
+
const base = fontState.official ?? fallbackBasePx()
|
|
437
|
+
const target = typeof state.fontPx === 'number' ? state.fontPx : base
|
|
438
|
+
const scale = target / base
|
|
439
|
+
const root = document.documentElement
|
|
440
|
+
root.style.setProperty('--dsrb-font-base', base + 'px')
|
|
441
|
+
root.style.setProperty('--dsrb-font-scale', String(scale))
|
|
442
|
+
tag.disabled = Math.abs(scale - 1) < 0.001 // 无缩放 → 样式表彻底停用,回到官方原样
|
|
443
|
+
}
|
|
444
|
+
/** 写官方字号(带自我写入标记,避免 themes/change 回环打架)。 */
|
|
445
|
+
function writeOfficialFont(px) {
|
|
446
|
+
if (!fontSvc || typeof fontSvc.setFontSize !== 'function') return
|
|
447
|
+
if (fontState.official === px) return
|
|
448
|
+
fontSelfWrite = true
|
|
449
|
+
try {
|
|
450
|
+
fontSvc.setFontSize(px)
|
|
451
|
+
} catch {
|
|
452
|
+
// 官方校验拒绝时忽略(理论不可达:调用处已夹在 10–22 内)
|
|
453
|
+
}
|
|
454
|
+
fontSelfWrite = false
|
|
455
|
+
}
|
|
456
|
+
// ── 工具 ────────────────────────────────────────────────────────────
|
|
457
|
+
function el(tag, className, text) {
|
|
458
|
+
const node = document.createElement(tag)
|
|
459
|
+
if (className) node.className = className
|
|
460
|
+
if (text !== undefined) node.textContent = text
|
|
461
|
+
return node
|
|
462
|
+
}
|
|
463
|
+
/** 整条长条可点(含开关本体):鼠标路径唯一 —— 本处理器显式翻转并 preventDefault
|
|
464
|
+
* (input 已 pointer-events:none、外壳非 label,不会叠加原生翻转);键盘派生的
|
|
465
|
+
* click 目标恰为 input,交还原生 change 路径。 */
|
|
466
|
+
function bindRowToggle(row, toggle, apply) {
|
|
467
|
+
row.addEventListener('click', (e) => {
|
|
468
|
+
if (e.target === toggle) return // 键盘空格:走原生(change 监听里统一 apply)
|
|
469
|
+
e.preventDefault()
|
|
470
|
+
toggle.checked = !toggle.checked
|
|
471
|
+
apply()
|
|
472
|
+
})
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// ── 插件本体 ────────────────────────────────────────────────────────
|
|
476
|
+
function apply(ctx) {
|
|
477
|
+
try {
|
|
478
|
+
// 热重载:DSH 监视插件文件变化,会再次执行本客户端。先拆旧实例、再按 id
|
|
479
|
+
// 清扫遗留节点、最后重建 —— 页面上运行的必然是最新代码(旧实例的监听闭包
|
|
480
|
+
// 随节点删除失联、残留定时器空转,均无害)。
|
|
481
|
+
const prevDispose = window.__dsrbDispose
|
|
482
|
+
if (typeof prevDispose === 'function') {
|
|
483
|
+
try { prevDispose() } catch { /* 旧实例拆除失败也不阻塞重建 */ }
|
|
484
|
+
}
|
|
485
|
+
for (const staleId of [FAB_ID, PANEL_ID, STYLE_ID, HIDE_STYLE_ID, HIDE_TOP_STYLE_ID, FONT_STYLE_ID, FONT_PICK_STYLE_ID, FS_STYLE_ID]) {
|
|
486
|
+
const staleNode = document.getElementById(staleId)
|
|
487
|
+
if (staleNode) staleNode.remove()
|
|
488
|
+
}
|
|
489
|
+
if (document.getElementById(FAB_ID)) return // 拆除后仍在=异常态,守住不重复挂载
|
|
490
|
+
|
|
491
|
+
state = readState()
|
|
492
|
+
|
|
493
|
+
// 1) 样式表:基础外观 + 各功能覆盖层
|
|
494
|
+
const style = el('style')
|
|
495
|
+
style.id = STYLE_ID
|
|
496
|
+
style.textContent = CSS
|
|
497
|
+
const hideTag = el('style')
|
|
498
|
+
hideTag.id = HIDE_STYLE_ID
|
|
499
|
+
hideTag.textContent = HIDE_CSS
|
|
500
|
+
const hideTopTag = el('style')
|
|
501
|
+
hideTopTag.id = HIDE_TOP_STYLE_ID
|
|
502
|
+
hideTopTag.textContent = HIDE_TOP_CSS
|
|
503
|
+
const fontTag = el('style')
|
|
504
|
+
fontTag.id = FONT_STYLE_ID
|
|
505
|
+
fontTag.textContent = FONT_CSS
|
|
506
|
+
const fontPickTag = el('style') // 字体选择覆盖层(未选择 / 主题未就绪时保持停用)
|
|
507
|
+
fontPickTag.id = FONT_PICK_STYLE_ID
|
|
508
|
+
const fsTag = el('style') // 全屏补偿层(常驻;仅存在全屏标记时才有匹配规则生效)
|
|
509
|
+
fsTag.id = FS_STYLE_ID
|
|
510
|
+
fsTag.textContent = FS_CSS
|
|
511
|
+
document.head.append(style, hideTag, hideTopTag, fontTag, fontPickTag, fsTag)
|
|
512
|
+
// disabled 必须在挂入文档后再设:未连接的 <style> 没有关联样式表,挂载前设置
|
|
513
|
+
// 会被静默丢弃(CSSOM 行为),否则隐藏层将无视已保存状态(字体层由 rebuildFontPick 按需启用)。
|
|
514
|
+
hideTag.disabled = !state.hideComposer
|
|
515
|
+
hideTopTag.disabled = !state.hideTop
|
|
516
|
+
fontPickTag.disabled = true
|
|
517
|
+
|
|
518
|
+
// 2) 悬浮圆钮
|
|
519
|
+
const fab = el('button', 'dsrb-fab', 'Aa')
|
|
520
|
+
fab.id = FAB_ID
|
|
521
|
+
fab.type = 'button'
|
|
522
|
+
fab.title = 'Happy Reader(拖动可移动)'
|
|
523
|
+
const [fx, fy] = clampPos(
|
|
524
|
+
state.x ?? window.innerWidth - FAB_SIZE - 16,
|
|
525
|
+
state.y ?? window.innerHeight - FAB_SIZE - 16,
|
|
526
|
+
FAB_SIZE,
|
|
527
|
+
FAB_SIZE,
|
|
528
|
+
)
|
|
529
|
+
fab.style.left = fx + 'px'
|
|
530
|
+
fab.style.top = fy + 'px'
|
|
531
|
+
document.body.appendChild(fab)
|
|
532
|
+
|
|
533
|
+
// 3) 面板
|
|
534
|
+
const panel = el('div', 'dsrb-panel')
|
|
535
|
+
panel.id = PANEL_ID
|
|
536
|
+
|
|
537
|
+
const headRow = el('div', 'dsrb-head')
|
|
538
|
+
const title = el('h4')
|
|
539
|
+
title.append('Happy Reader')
|
|
540
|
+
const headHint = el('span', 'dsrb-headHint', '点面板外关闭')
|
|
541
|
+
const closeBtn = el('button', 'dsrb-x', '×')
|
|
542
|
+
closeBtn.type = 'button'
|
|
543
|
+
closeBtn.title = '关闭'
|
|
544
|
+
headRow.append(title, headHint, closeBtn)
|
|
545
|
+
|
|
546
|
+
// 行:隐藏输入框(整条可点)
|
|
547
|
+
const rowHide = el('div', 'dsrb-row dsrb-row-click')
|
|
548
|
+
const switchBox = el('span', 'dsrb-switch') // 用 span 而非 label:没有原生转发激活面
|
|
549
|
+
const hideToggle = el('input')
|
|
550
|
+
hideToggle.type = 'checkbox'
|
|
551
|
+
switchBox.append(hideToggle, el('span'))
|
|
552
|
+
const hideMain = el('div', 'dsrb-rowMain')
|
|
553
|
+
hideMain.append(el('div', 'dsrb-rowTitle', '隐藏输入框'), el('div', 'dsrb-rowSub', '收起底部输入区'))
|
|
554
|
+
rowHide.append(switchBox, hideMain)
|
|
555
|
+
|
|
556
|
+
// 行:隐藏上边栏(整条可点)
|
|
557
|
+
const rowTop = el('div', 'dsrb-row dsrb-row-click')
|
|
558
|
+
const topBox = el('span', 'dsrb-switch')
|
|
559
|
+
const topToggle = el('input')
|
|
560
|
+
topToggle.type = 'checkbox'
|
|
561
|
+
topBox.append(topToggle, el('span'))
|
|
562
|
+
const topMain = el('div', 'dsrb-rowMain')
|
|
563
|
+
topMain.append(el('div', 'dsrb-rowTitle', '隐藏上边栏'), el('div', 'dsrb-rowSub', '收起会话标题栏'))
|
|
564
|
+
rowTop.append(topBox, topMain)
|
|
565
|
+
|
|
566
|
+
// 行:行距 / 字距(面板最下方;默认 = 官方原样)
|
|
567
|
+
const rowType = el('div', 'dsrb-row')
|
|
568
|
+
const typeMain = el('div', 'dsrb-rowMain')
|
|
569
|
+
typeMain.append(el('div', 'dsrb-rowTitle', '行距 / 字距'), el('div', 'dsrb-rowSub', '默认 = 官方原样'))
|
|
570
|
+
const typePair = el('div', 'dsrb-pair')
|
|
571
|
+
const lhCol = el('div', 'dsrb-pairCol')
|
|
572
|
+
const lhMini = el('div', 'dsrb-mini')
|
|
573
|
+
const lhVal = el('b', null, '默认')
|
|
574
|
+
lhMini.append('行距 ', lhVal)
|
|
575
|
+
const lhStepper = el('div', 'dsrb-stepper')
|
|
576
|
+
const lhMinus = el('button', null, '−')
|
|
577
|
+
const lhPlus = el('button', null, '+')
|
|
578
|
+
lhMinus.type = lhPlus.type = 'button'
|
|
579
|
+
lhStepper.append(lhMinus, lhPlus)
|
|
580
|
+
lhCol.append(lhMini, lhStepper)
|
|
581
|
+
const lsCol = el('div', 'dsrb-pairCol')
|
|
582
|
+
const lsMini = el('div', 'dsrb-mini')
|
|
583
|
+
const lsVal = el('b', null, '默认')
|
|
584
|
+
lsMini.append('字距 ', lsVal)
|
|
585
|
+
const lsStepper = el('div', 'dsrb-stepper')
|
|
586
|
+
const lsMinus = el('button', null, '−')
|
|
587
|
+
const lsPlus = el('button', null, '+')
|
|
588
|
+
lsMinus.type = lsPlus.type = 'button'
|
|
589
|
+
lsStepper.append(lsMinus, lsPlus)
|
|
590
|
+
lsCol.append(lsMini, lsStepper)
|
|
591
|
+
typePair.append(lhCol, lsCol)
|
|
592
|
+
typeMain.append(typePair)
|
|
593
|
+
rowType.append(typeMain)
|
|
594
|
+
|
|
595
|
+
// 行:字号
|
|
596
|
+
const rowFont = el('div', 'dsrb-row')
|
|
597
|
+
const fontMain = el('div', 'dsrb-rowMain')
|
|
598
|
+
const fontTitle = el('div', 'dsrb-rowTitle')
|
|
599
|
+
const fontVal = el('b', null, '…')
|
|
600
|
+
fontTitle.append('字号 ', fontVal)
|
|
601
|
+
fontMain.append(fontTitle, el('div', 'dsrb-rowSub', '与官方设置同步'))
|
|
602
|
+
const stepper = el('div', 'dsrb-stepper')
|
|
603
|
+
const fontMinus = el('button', null, '−')
|
|
604
|
+
const fontPlus = el('button', null, '+')
|
|
605
|
+
fontMinus.type = fontPlus.type = 'button'
|
|
606
|
+
stepper.append(fontMinus, fontPlus)
|
|
607
|
+
rowFont.append(fontMain, stepper)
|
|
608
|
+
|
|
609
|
+
// 行:正文字体(中文、西文两个独立下拉压缩在同一行;中文下拉只接管中日韩
|
|
610
|
+
// 字符与中文标点,西文下拉只接管拉丁字母/数字/西文标点,各自独立、互不串扰)
|
|
611
|
+
const rowTextFont = el('div', 'dsrb-row')
|
|
612
|
+
const textFontMain = el('div', 'dsrb-rowMain')
|
|
613
|
+
textFontMain.append(el('div', 'dsrb-rowTitle', '正文字体'), el('div', 'dsrb-rowSub', '仅显示本机已装字体'))
|
|
614
|
+
const textPair = el('div', 'dsrb-pair')
|
|
615
|
+
const cjkCol = el('div', 'dsrb-pairCol')
|
|
616
|
+
cjkCol.append(el('div', 'dsrb-mini', '中文'))
|
|
617
|
+
const textCjkSelect = el('select', 'dsrb-select')
|
|
618
|
+
cjkCol.append(textCjkSelect)
|
|
619
|
+
const latinCol = el('div', 'dsrb-pairCol')
|
|
620
|
+
latinCol.append(el('div', 'dsrb-mini', '西文'))
|
|
621
|
+
const textLatinSelect = el('select', 'dsrb-select')
|
|
622
|
+
latinCol.append(textLatinSelect)
|
|
623
|
+
textPair.append(cjkCol, latinCol)
|
|
624
|
+
textFontMain.append(textPair)
|
|
625
|
+
rowTextFont.append(textFontMain)
|
|
626
|
+
|
|
627
|
+
// 行:代码字体(消息内代码与代码块)
|
|
628
|
+
const rowCodeFont = el('div', 'dsrb-row')
|
|
629
|
+
const codeFontMain = el('div', 'dsrb-rowMain')
|
|
630
|
+
codeFontMain.append(el('div', 'dsrb-rowTitle', '代码字体'), el('div', 'dsrb-rowSub', '仅显示本机已装字体'))
|
|
631
|
+
const codeFontSelect = el('select', 'dsrb-select')
|
|
632
|
+
codeFontMain.append(codeFontSelect)
|
|
633
|
+
rowCodeFont.append(codeFontMain)
|
|
634
|
+
|
|
635
|
+
// 行:内容宽度
|
|
636
|
+
const rowWidth = el('div', 'dsrb-row')
|
|
637
|
+
const widthMain = el('div', 'dsrb-rowMain')
|
|
638
|
+
const widthTitle = el('div', 'dsrb-rowTitle')
|
|
639
|
+
const widthVal = el('b', null, '…')
|
|
640
|
+
widthTitle.append('内容宽度 ', widthVal)
|
|
641
|
+
widthMain.append(widthTitle, el('div', 'dsrb-rowSub', '与官方手柄同步'))
|
|
642
|
+
const widthRow = el('div', 'dsrb-widthRow')
|
|
643
|
+
const widthSlider = el('input')
|
|
644
|
+
widthSlider.type = 'range'
|
|
645
|
+
widthSlider.step = '5'
|
|
646
|
+
widthSlider.min = String(CONTENT_MIN)
|
|
647
|
+
widthSlider.max = String(CONTENT_MIN)
|
|
648
|
+
const widthReset = el('button', null, '自动')
|
|
649
|
+
widthReset.type = 'button'
|
|
650
|
+
widthRow.append(widthSlider, widthReset)
|
|
651
|
+
widthMain.append(widthRow)
|
|
652
|
+
rowWidth.append(widthMain)
|
|
653
|
+
|
|
654
|
+
// 行:全屏(整条可点;开关状态与真实全屏状态双向同步)
|
|
655
|
+
const rowFullscreen = el('div', 'dsrb-row dsrb-row-click')
|
|
656
|
+
const fsBox = el('span', 'dsrb-switch')
|
|
657
|
+
const fsToggle = el('input')
|
|
658
|
+
fsToggle.type = 'checkbox'
|
|
659
|
+
fsBox.append(fsToggle, el('span'))
|
|
660
|
+
const fsMain = el('div', 'dsrb-rowMain')
|
|
661
|
+
fsMain.append(el('div', 'dsrb-rowTitle', '全屏'), el('div', 'dsrb-rowSub', '铺满屏幕 · Esc 退出'))
|
|
662
|
+
rowFullscreen.append(fsBox, fsMain)
|
|
663
|
+
|
|
664
|
+
// 三段式结构:拉拽条 / 滚动内容区 / 拉拽条 —— 内容在中间滚动,两条拉拽条
|
|
665
|
+
// 永远贴住面板的视觉上/下缘(滚动容器里的绝对定位元素会随内容滚走,不能用绝对定位)
|
|
666
|
+
const resizeTop = el('div', 'dsrb-resize-top')
|
|
667
|
+
const resizeBottom = el('div', 'dsrb-resize')
|
|
668
|
+
const panelBody = el('div', 'dsrb-body')
|
|
669
|
+
panelBody.append(headRow, rowFont, rowTextFont, rowCodeFont, rowWidth, rowFullscreen, rowHide, rowTop, rowType)
|
|
670
|
+
panel.append(resizeTop, panelBody, resizeBottom)
|
|
671
|
+
panel.style.display = state.open ? '' : 'none'
|
|
672
|
+
document.body.appendChild(panel)
|
|
673
|
+
|
|
674
|
+
// ── 面板定位(四个方位择一,绝不压住按钮;避开右上角系统按钮条) ──
|
|
675
|
+
// 右上角三枚系统按钮是 Electron WCO 原生叠层(绘制在网页层之上、无法覆盖):
|
|
676
|
+
// 面板伸进该区右上角会被压住 —— 落点横跨时纵向从条底之下开始。条高取官方
|
|
677
|
+
// 窗口标题条变量(全屏无此条,按 0 处理);条宽 = 三枚按钮叠层宽(DIP)。
|
|
678
|
+
const titlebarH =
|
|
679
|
+
Number.parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--dsh-windows-titlebar-height')) || 40
|
|
680
|
+
const CAP_W = 138
|
|
681
|
+
function placePanel() {
|
|
682
|
+
const r = fab.getBoundingClientRect()
|
|
683
|
+
panel.style.height = ''
|
|
684
|
+
panel.style.maxHeight = '' // 先按自然高度测量
|
|
685
|
+
const pw = panel.offsetWidth || PANEL_WIDTH
|
|
686
|
+
const natural = panel.offsetHeight || 240
|
|
687
|
+
const GAP = 6
|
|
688
|
+
const M = 8
|
|
689
|
+
const vw = window.innerWidth
|
|
690
|
+
const vh = window.innerHeight
|
|
691
|
+
const CAP_H = document.fullscreenElement ? 0 : titlebarH
|
|
692
|
+
// 定位按"实际将显示的高度"算 —— 底缘才能与按钮对齐,不会忽远忽近
|
|
693
|
+
const desired = typeof state.panelH === 'number' ? state.panelH : Math.min(natural, PANEL_MAX_H)
|
|
694
|
+
const ph = Math.max(PANEL_MIN_H, Math.min(desired, vh - 2 * M - BORDER_Y))
|
|
695
|
+
const clampX = (x) => Math.min(Math.max(M, x), Math.max(M, vw - pw - M))
|
|
696
|
+
const clampY = (y, yMin) => Math.min(Math.max(yMin, y), Math.max(yMin, vh - ph - BORDER_Y - M))
|
|
697
|
+
let best = null
|
|
698
|
+
if (typeof state.panelX === 'number' && typeof state.panelY === 'number') {
|
|
699
|
+
// 用户拖过位置 → 用记住的位置;仍做两件保护:避开右上角系统按钮条;
|
|
700
|
+
// 若与按钮重叠(按钮可能后来被拖到附近)则放弃记忆、回自动布局
|
|
701
|
+
const sx = clampX(state.panelX)
|
|
702
|
+
const sy = clampY(state.panelY, CAP_H > 0 && sx + pw > vw - CAP_W ? CAP_H : M)
|
|
703
|
+
const overlapsFab = sx < r.right + GAP && sx + pw > r.left - GAP && sy < r.bottom + GAP && sy + ph > r.top - GAP
|
|
704
|
+
if (overlapsFab) {
|
|
705
|
+
state.panelX = null
|
|
706
|
+
state.panelY = null
|
|
707
|
+
writeState({ panelX: null, panelY: null })
|
|
708
|
+
} else {
|
|
709
|
+
best = { x: sx, y: sy }
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
if (!best) {
|
|
713
|
+
const cx = r.left + r.width / 2 - pw / 2
|
|
714
|
+
const candidates = [
|
|
715
|
+
{ x: r.left - pw - GAP, y: r.bottom - ph }, // 左(底缘与按钮底对齐,贴近右下)
|
|
716
|
+
{ x: r.right + GAP, y: r.bottom - ph }, // 右(同上)
|
|
717
|
+
{ x: cx, y: r.top - ph - GAP }, // 上
|
|
718
|
+
{ x: cx, y: r.bottom + GAP }, // 下
|
|
719
|
+
]
|
|
720
|
+
const hits = (x, y) =>
|
|
721
|
+
x < r.right + GAP && x + pw > r.left - GAP && y < r.bottom + GAP && y + ph > r.top - GAP
|
|
722
|
+
for (const c of candidates) {
|
|
723
|
+
const x = clampX(c.x)
|
|
724
|
+
const yMin = CAP_H > 0 && x + pw > vw - CAP_W ? CAP_H : M
|
|
725
|
+
const y = clampY(c.y, yMin)
|
|
726
|
+
if (!hits(x, y)) {
|
|
727
|
+
best = { x, y }
|
|
728
|
+
break
|
|
729
|
+
}
|
|
730
|
+
// 兜底:记录重叠面积最小的候选(窗口极小时才会用到)
|
|
731
|
+
const ox = Math.max(0, Math.min(x + pw, r.right + GAP) - Math.max(x, r.left - GAP))
|
|
732
|
+
const oy = Math.max(0, Math.min(y + ph, r.bottom + GAP) - Math.max(y, r.top - GAP))
|
|
733
|
+
const area = ox * oy
|
|
734
|
+
if (!best || area < best.area) best = { x, y, area }
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
panel.style.left = best.x + 'px'
|
|
738
|
+
panel.style.top = best.y + 'px'
|
|
739
|
+
// 应用最终高度:以定位时采用的 ph 为准,再按落点与视口底缘的剩余空间收紧
|
|
740
|
+
const finalH = Math.max(PANEL_MIN_H, Math.min(ph, vh - best.y - M - BORDER_Y))
|
|
741
|
+
panel.style.maxHeight = finalH + 'px'
|
|
742
|
+
if (typeof state.panelH === 'number') panel.style.height = finalH + 'px'
|
|
743
|
+
}
|
|
744
|
+
function togglePanel(open) {
|
|
745
|
+
state.open = open
|
|
746
|
+
panel.style.display = open ? '' : 'none'
|
|
747
|
+
if (open) {
|
|
748
|
+
renderWidthRow()
|
|
749
|
+
renderFontRow()
|
|
750
|
+
placePanel()
|
|
751
|
+
}
|
|
752
|
+
writeState({ open })
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// ── 面板渲染 ────────────────────────────────────────────────────
|
|
756
|
+
function setFontPx(v) {
|
|
757
|
+
const next = Math.min(FONT_MAX, Math.max(FONT_MIN, Math.round(v)))
|
|
758
|
+
state.fontPx = next
|
|
759
|
+
if (next <= FONT_OFFICIAL_MAX) writeOfficialFont(next) // 官方范围内:直写官方设置
|
|
760
|
+
// 超出 22:官方设置保持原样,仅用缩放层补齐
|
|
761
|
+
applyFont()
|
|
762
|
+
renderFontRow()
|
|
763
|
+
writeState({ fontPx: next })
|
|
764
|
+
}
|
|
765
|
+
function renderFontRow() {
|
|
766
|
+
fontVal.textContent = typeof state.fontPx === 'number' ? state.fontPx + 'px' : '—'
|
|
767
|
+
fontMinus.disabled = typeof state.fontPx !== 'number' || state.fontPx <= FONT_MIN
|
|
768
|
+
fontPlus.disabled = typeof state.fontPx !== 'number' || state.fontPx >= FONT_MAX
|
|
769
|
+
}
|
|
770
|
+
function renderWidthRow() {
|
|
771
|
+
const info = currentWidthInfo()
|
|
772
|
+
if (!info) {
|
|
773
|
+
widthSlider.disabled = true
|
|
774
|
+
widthReset.disabled = true
|
|
775
|
+
widthVal.textContent = '—(打开会话后可用)'
|
|
776
|
+
return
|
|
777
|
+
}
|
|
778
|
+
widthSlider.min = String(info.min)
|
|
779
|
+
widthSlider.max = String(info.max)
|
|
780
|
+
widthSlider.disabled = info.max <= CONTENT_MIN
|
|
781
|
+
widthReset.disabled = info.auto
|
|
782
|
+
if (!widthDragging) {
|
|
783
|
+
widthSlider.value = String(Math.round(Math.min(Math.max(info.px, info.min), info.max)))
|
|
784
|
+
}
|
|
785
|
+
widthVal.textContent = Math.round(info.px) + 'px'
|
|
786
|
+
}
|
|
787
|
+
function renderAll() {
|
|
788
|
+
hideToggle.checked = !!state.hideComposer
|
|
789
|
+
topToggle.checked = !!state.hideTop
|
|
790
|
+
textCjkSelect.value = state.textFontCjk ?? ''
|
|
791
|
+
textLatinSelect.value = state.textFontLatin ?? ''
|
|
792
|
+
codeFontSelect.value = state.codeFont ?? ''
|
|
793
|
+
renderFontRow()
|
|
794
|
+
renderTypeRow()
|
|
795
|
+
renderWidthRow()
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// ── 功能 1:隐藏输入区(开关 + 整条长条可点) ────────────────────
|
|
799
|
+
function applyHideComposer() {
|
|
800
|
+
state.hideComposer = hideToggle.checked
|
|
801
|
+
hideTag.disabled = !state.hideComposer
|
|
802
|
+
writeState({ hideComposer: state.hideComposer })
|
|
803
|
+
}
|
|
804
|
+
hideToggle.addEventListener('change', applyHideComposer)
|
|
805
|
+
bindRowToggle(rowHide, hideToggle, applyHideComposer)
|
|
806
|
+
|
|
807
|
+
// ── 功能 2:隐藏上边栏(开关 + 整条长条可点) ────────────────────
|
|
808
|
+
function applyHideTop() {
|
|
809
|
+
state.hideTop = topToggle.checked
|
|
810
|
+
hideTopTag.disabled = !state.hideTop
|
|
811
|
+
writeState({ hideTop: state.hideTop })
|
|
812
|
+
}
|
|
813
|
+
topToggle.addEventListener('change', applyHideTop)
|
|
814
|
+
bindRowToggle(rowTop, topToggle, applyHideTop)
|
|
815
|
+
|
|
816
|
+
// ── 功能 6:全屏(开关与真实全屏状态双向同步;Esc 退出后自动回位) ──
|
|
817
|
+
function inPageFullscreen() {
|
|
818
|
+
return document.fullscreenElement === document.documentElement
|
|
819
|
+
}
|
|
820
|
+
function syncFullscreen() {
|
|
821
|
+
// 事实来源是 document.fullscreenElement:可能不是本插件打开的(Esc、或应用内
|
|
822
|
+
// 其它元素全屏),因此由 fullscreenchange 事件无条件校正开关与补偿标记
|
|
823
|
+
const on = inPageFullscreen()
|
|
824
|
+
fsToggle.checked = on
|
|
825
|
+
const root = document.documentElement
|
|
826
|
+
if (on) root.setAttribute(FS_ATTR, '')
|
|
827
|
+
else root.removeAttribute(FS_ATTR)
|
|
828
|
+
// 全屏切换会改变系统按钮条的有无 → 面板开着时当场重定位
|
|
829
|
+
if (state.open) placePanel()
|
|
830
|
+
}
|
|
831
|
+
function applyFullscreen() {
|
|
832
|
+
// 进入全屏必须发生在用户手势内:本函数由开关 change / 整条点击同步触发,满足
|
|
833
|
+
const want = fsToggle.checked
|
|
834
|
+
const fail = (err) => {
|
|
835
|
+
// 被拒绝(无手势 / 被策略拦截等):把开关拨回真实状态,功能静默降级
|
|
836
|
+
console.warn('[dsh-happy-reader] 全屏切换失败:', err)
|
|
837
|
+
syncFullscreen()
|
|
838
|
+
}
|
|
839
|
+
try {
|
|
840
|
+
const p = want ? document.documentElement.requestFullscreen() : document.exitFullscreen()
|
|
841
|
+
if (p) p.catch(fail)
|
|
842
|
+
} catch (err) {
|
|
843
|
+
fail(err)
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
document.addEventListener('fullscreenchange', syncFullscreen)
|
|
847
|
+
fsToggle.addEventListener('change', applyFullscreen)
|
|
848
|
+
bindRowToggle(rowFullscreen, fsToggle, applyFullscreen)
|
|
849
|
+
syncFullscreen() // 初始对齐(热重载等场景下若已在全屏,开关与标记立即正确)
|
|
850
|
+
|
|
851
|
+
// ── 功能 3:字号 ────────────────────────────────────────────────
|
|
852
|
+
fontMinus.addEventListener('click', () => {
|
|
853
|
+
setFontPx((typeof state.fontPx === 'number' ? state.fontPx : fontState.official ?? 16) - 1)
|
|
854
|
+
})
|
|
855
|
+
fontPlus.addEventListener('click', () => {
|
|
856
|
+
setFontPx((typeof state.fontPx === 'number' ? state.fontPx : fontState.official ?? 16) + 1)
|
|
857
|
+
})
|
|
858
|
+
|
|
859
|
+
// ── 功能 5:字体选择(正文·中文 / 正文·西文 / 代码) ───────────
|
|
860
|
+
textCjkSelect.addEventListener('change', () => {
|
|
861
|
+
const id = textCjkSelect.value || null
|
|
862
|
+
state.textFontCjk = fontChoice(availCjk, id) ? id : null
|
|
863
|
+
textCjkSelect.value = state.textFontCjk ?? ''
|
|
864
|
+
writeState({ textFontCjk: state.textFontCjk })
|
|
865
|
+
fontPickBuilt = false
|
|
866
|
+
rebuildFontPick()
|
|
867
|
+
})
|
|
868
|
+
textLatinSelect.addEventListener('change', () => {
|
|
869
|
+
const id = textLatinSelect.value || null
|
|
870
|
+
state.textFontLatin = fontChoice(availLatin, id) ? id : null
|
|
871
|
+
textLatinSelect.value = state.textFontLatin ?? ''
|
|
872
|
+
writeState({ textFontLatin: state.textFontLatin })
|
|
873
|
+
fontPickBuilt = false
|
|
874
|
+
rebuildFontPick()
|
|
875
|
+
})
|
|
876
|
+
codeFontSelect.addEventListener('change', () => {
|
|
877
|
+
const id = codeFontSelect.value || null
|
|
878
|
+
state.codeFont = fontChoice(availCode, id) ? id : null
|
|
879
|
+
codeFontSelect.value = state.codeFont ?? ''
|
|
880
|
+
writeState({ codeFont: state.codeFont })
|
|
881
|
+
fontPickBuilt = false
|
|
882
|
+
rebuildFontPick()
|
|
883
|
+
})
|
|
884
|
+
|
|
885
|
+
// ── 功能 7:行距 / 字距(0.1 步进;到达边界即回"默认",覆盖层随之停用) ──
|
|
886
|
+
function setLineHeight(v) {
|
|
887
|
+
const next = Math.min(LINEH_MAX, Math.max(LINEH_MIN, Math.round(v * 10) / 10))
|
|
888
|
+
state.lineHeight = next > LINEH_MIN ? next : null
|
|
889
|
+
fontPickBuilt = false
|
|
890
|
+
rebuildFontPick()
|
|
891
|
+
renderTypeRow()
|
|
892
|
+
writeState({ lineHeight: state.lineHeight })
|
|
893
|
+
}
|
|
894
|
+
function setLetterSpacing(v) {
|
|
895
|
+
const next = Math.min(LETTERSP_MAX, Math.max(LETTERSP_MIN, Math.round(v * 10) / 10))
|
|
896
|
+
state.letterSpacing = next !== 0 ? next : null
|
|
897
|
+
fontPickBuilt = false
|
|
898
|
+
rebuildFontPick()
|
|
899
|
+
renderTypeRow()
|
|
900
|
+
writeState({ letterSpacing: state.letterSpacing })
|
|
901
|
+
}
|
|
902
|
+
function renderTypeRow() {
|
|
903
|
+
lhVal.textContent = state.lineHeight ? state.lineHeight.toFixed(1) + '×' : '默认'
|
|
904
|
+
lsVal.textContent = state.letterSpacing ? state.letterSpacing.toFixed(1) + 'px' : '默认'
|
|
905
|
+
}
|
|
906
|
+
lhMinus.addEventListener('click', () => setLineHeight((state.lineHeight ?? LINEH_MIN) - 0.1))
|
|
907
|
+
lhPlus.addEventListener('click', () => setLineHeight((state.lineHeight ?? LINEH_MIN) + 0.1))
|
|
908
|
+
lsMinus.addEventListener('click', () => setLetterSpacing((state.letterSpacing ?? 0) - 0.1))
|
|
909
|
+
lsPlus.addEventListener('click', () => setLetterSpacing((state.letterSpacing ?? 0) + 0.1))
|
|
910
|
+
|
|
911
|
+
// ── 功能 4:内容宽度 ────────────────────────────────────────────
|
|
912
|
+
let widthDragging = false // 我方滑块拖动中
|
|
913
|
+
officialDragging = false // 官方手柄拖动中(工厂层辅助函数共享)
|
|
914
|
+
let officialDragMoved = false
|
|
915
|
+
let officialDragStart = null
|
|
916
|
+
let officialDragAdopt = false
|
|
917
|
+
function setWidthPx(v) {
|
|
918
|
+
if (v === null) {
|
|
919
|
+
state.widthPx = null
|
|
920
|
+
} else {
|
|
921
|
+
const t = widthTargets()
|
|
922
|
+
if (!t) return // 无会话容器(滑块与"自动"此时均处于禁用态)
|
|
923
|
+
state.widthPx = Math.min(Math.max(Math.round(v), CONTENT_MIN), extCapFor(t.column))
|
|
924
|
+
}
|
|
925
|
+
applyWidthState()
|
|
926
|
+
writeState({ widthPx: state.widthPx })
|
|
927
|
+
renderWidthRow()
|
|
928
|
+
}
|
|
929
|
+
widthSlider.addEventListener('pointerdown', () => {
|
|
930
|
+
widthDragging = true
|
|
931
|
+
})
|
|
932
|
+
function onWindowPointerUp() {
|
|
933
|
+
if (widthDragging) {
|
|
934
|
+
widthDragging = false
|
|
935
|
+
renderWidthRow()
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
window.addEventListener('pointerup', onWindowPointerUp)
|
|
939
|
+
widthSlider.addEventListener('input', () => {
|
|
940
|
+
setWidthPx(Number(widthSlider.value))
|
|
941
|
+
})
|
|
942
|
+
widthReset.addEventListener('click', () => {
|
|
943
|
+
setWidthPx(null)
|
|
944
|
+
})
|
|
945
|
+
|
|
946
|
+
// 官方手柄 / 面板外点击(document 捕获阶段,先于应用本体收到事件)
|
|
947
|
+
function onDocPointerDown(e) {
|
|
948
|
+
const target = e.target
|
|
949
|
+
const handle = target && target.closest ? target.closest('[data-width-handle]') : null
|
|
950
|
+
if (handle) {
|
|
951
|
+
officialDragging = true
|
|
952
|
+
officialDragMoved = false
|
|
953
|
+
officialDragStart = { x: e.clientX, y: e.clientY }
|
|
954
|
+
const t = widthTargets()
|
|
955
|
+
officialDragAdopt = !!(t && state.widthPx != null && state.widthPx > officialMaxFor(t.column))
|
|
956
|
+
}
|
|
957
|
+
if (state.open && !panel.contains(target) && !fab.contains(target)) {
|
|
958
|
+
togglePanel(false)
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
function onDocPointerMove(e) {
|
|
962
|
+
if (!officialDragging || officialDragMoved) return
|
|
963
|
+
if (!officialDragStart) return
|
|
964
|
+
if (Math.abs(e.clientX - officialDragStart.x) + Math.abs(e.clientY - officialDragStart.y) < 4) return
|
|
965
|
+
officialDragMoved = true
|
|
966
|
+
if (officialDragAdopt) {
|
|
967
|
+
// 交还官方:移除覆盖,让拖动实时预览
|
|
968
|
+
const t = widthTargets()
|
|
969
|
+
if (t) t.container.style.removeProperty('--dsh-chat-content-width')
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
function onDocPointerUp() {
|
|
973
|
+
if (!officialDragging) return
|
|
974
|
+
const adopt = officialDragAdopt && officialDragMoved
|
|
975
|
+
officialDragMoved = false
|
|
976
|
+
officialDragStart = null
|
|
977
|
+
officialDragAdopt = false
|
|
978
|
+
if (!adopt) {
|
|
979
|
+
officialDragging = false
|
|
980
|
+
return
|
|
981
|
+
}
|
|
982
|
+
// 采纳期间保持"官方拖动中"状态:定点漂移修复不会抢在官方提交之前插手
|
|
983
|
+
setTimeout(() => {
|
|
984
|
+
officialDragging = false
|
|
985
|
+
const pref = readWidthPreference()
|
|
986
|
+
state.widthPx = pref != null ? pref : null
|
|
987
|
+
applyWidthState()
|
|
988
|
+
writeState({ widthPx: state.widthPx })
|
|
989
|
+
renderWidthRow()
|
|
990
|
+
}, 0)
|
|
991
|
+
}
|
|
992
|
+
function onDocPointerCancel() {
|
|
993
|
+
officialDragging = false
|
|
994
|
+
officialDragMoved = false
|
|
995
|
+
officialDragStart = null
|
|
996
|
+
officialDragAdopt = false
|
|
997
|
+
}
|
|
998
|
+
document.addEventListener('pointerdown', onDocPointerDown, true)
|
|
999
|
+
document.addEventListener('pointermove', onDocPointerMove, true)
|
|
1000
|
+
document.addEventListener('pointerup', onDocPointerUp, true)
|
|
1001
|
+
document.addEventListener('pointercancel', onDocPointerCancel, true)
|
|
1002
|
+
|
|
1003
|
+
// 官方手柄拖动 / 窗口尺寸变化 → 目标元素 style 变化 → 反向同步面板
|
|
1004
|
+
let watchedTarget = null
|
|
1005
|
+
const widthObserver = new MutationObserver(() => {
|
|
1006
|
+
if (!widthDragging) renderWidthRow()
|
|
1007
|
+
})
|
|
1008
|
+
function attachWidthObserver() {
|
|
1009
|
+
const t = widthTargets()
|
|
1010
|
+
const target = t ? t.target : null
|
|
1011
|
+
if (target === watchedTarget) return
|
|
1012
|
+
widthObserver.disconnect()
|
|
1013
|
+
watchedTarget = target
|
|
1014
|
+
if (target) widthObserver.observe(target, { attributes: true, attributeFilter: ['style'] })
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
// 列宽突变(侧边栏开合只改 grid 列宽、不触发 window.resize)→ 用与官方
|
|
1018
|
+
// ConversationWidthControls 同款的 ResizeObserver 观察同一节点,把覆盖值/
|
|
1019
|
+
// 官方键/官方变量的收敛压到同一批回调内,开合过程不再出现滞留窄值的中间态。
|
|
1020
|
+
let watchedColumn = null
|
|
1021
|
+
const columnObserver = new ResizeObserver(() => {
|
|
1022
|
+
normalizeWidthDrift()
|
|
1023
|
+
if (state.open && !officialDragging && !widthDragging) renderWidthRow()
|
|
1024
|
+
})
|
|
1025
|
+
function attachColumnObserver() {
|
|
1026
|
+
const t = widthTargets()
|
|
1027
|
+
const container = t ? t.container : null
|
|
1028
|
+
if (container === watchedColumn) return
|
|
1029
|
+
columnObserver.disconnect()
|
|
1030
|
+
watchedColumn = container
|
|
1031
|
+
if (container) columnObserver.observe(container)
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
// ── 功能 3 的服务对接:订阅官方主题(字号基准与官方直连) ────────
|
|
1035
|
+
ctx.inject(['theme'], (tctx) => {
|
|
1036
|
+
fontSvc = tctx.theme
|
|
1037
|
+
let firstSync = true
|
|
1038
|
+
const sync = (snap) => {
|
|
1039
|
+
const n = snap && snap.fontSize
|
|
1040
|
+
if (typeof n === 'number' && n > 0) fontState.official = n
|
|
1041
|
+
if (!fontSelfWrite) {
|
|
1042
|
+
if (
|
|
1043
|
+
firstSync &&
|
|
1044
|
+
typeof state.fontPx === 'number' &&
|
|
1045
|
+
state.fontPx <= FONT_OFFICIAL_MAX &&
|
|
1046
|
+
state.fontPx !== fontState.official
|
|
1047
|
+
) {
|
|
1048
|
+
// 启动恢复:面板上次设定的字号(范围内)直写回官方设置,
|
|
1049
|
+
// 保证"范围内 ⇔ 官方值 === 面板值"的稳定关系
|
|
1050
|
+
firstSync = false
|
|
1051
|
+
applyFont()
|
|
1052
|
+
writeOfficialFont(state.fontPx) // 官方更新后会再次进入本函数(自写标记路径)
|
|
1053
|
+
return
|
|
1054
|
+
}
|
|
1055
|
+
firstSync = false
|
|
1056
|
+
if (typeof state.fontPx !== 'number') state.fontPx = fontState.official
|
|
1057
|
+
else if (state.fontPx <= FONT_OFFICIAL_MAX) state.fontPx = fontState.official // 范围内以官方为准
|
|
1058
|
+
// 超出 22 的绝对目标保持不动,倍率自动适配
|
|
1059
|
+
writeState({ fontPx: state.fontPx })
|
|
1060
|
+
renderFontRow()
|
|
1061
|
+
}
|
|
1062
|
+
applyFont()
|
|
1063
|
+
}
|
|
1064
|
+
// 先订阅、再首读:启动恢复的 setFontSize 会同步发布 theme/change,
|
|
1065
|
+
// 若先读后订会错过这次回声(官方值会暂时读旧)
|
|
1066
|
+
tctx.on('theme/change', (snap) => sync(snap))
|
|
1067
|
+
try {
|
|
1068
|
+
sync(tctx.theme.getTheme())
|
|
1069
|
+
} catch {
|
|
1070
|
+
applyFont() // 服务首读异常时至少按兜底基准绘制一次
|
|
1071
|
+
}
|
|
1072
|
+
})
|
|
1073
|
+
|
|
1074
|
+
// ── 启动时应用已保存的功能状态 ──────────────────────────────────
|
|
1075
|
+
if (typeof state.fontPx === 'number') {
|
|
1076
|
+
state.fontPx = Math.min(FONT_MAX, Math.max(FONT_MIN, Math.round(state.fontPx)))
|
|
1077
|
+
} else {
|
|
1078
|
+
state.fontPx = null
|
|
1079
|
+
}
|
|
1080
|
+
if (typeof state.widthPx !== 'number' || !(state.widthPx > 0)) state.widthPx = null
|
|
1081
|
+
if (typeof state.lineHeight === 'number') {
|
|
1082
|
+
const lh = Math.min(LINEH_MAX, Math.max(LINEH_MIN, Math.round(state.lineHeight * 10) / 10))
|
|
1083
|
+
state.lineHeight = lh > LINEH_MIN ? lh : null
|
|
1084
|
+
} else state.lineHeight = null
|
|
1085
|
+
if (typeof state.letterSpacing === 'number') {
|
|
1086
|
+
const ls = Math.min(LETTERSP_MAX, Math.max(LETTERSP_MIN, Math.round(state.letterSpacing * 10) / 10))
|
|
1087
|
+
state.letterSpacing = ls !== 0 ? ls : null
|
|
1088
|
+
} else state.letterSpacing = null
|
|
1089
|
+
state.panelX = typeof state.panelX === 'number' ? state.panelX : null
|
|
1090
|
+
state.panelY = typeof state.panelY === 'number' ? state.panelY : null
|
|
1091
|
+
state.panelH = typeof state.panelH === 'number' && state.panelH > 0 ? state.panelH : null
|
|
1092
|
+
|
|
1093
|
+
// ── 字体选择:本机可用性过滤 + 选择恢复 + 覆盖层生成 ────────────
|
|
1094
|
+
function fillFontSelect(select, list) {
|
|
1095
|
+
select.textContent = ''
|
|
1096
|
+
const def = el('option', null, '默认(官方)')
|
|
1097
|
+
def.value = ''
|
|
1098
|
+
select.append(def)
|
|
1099
|
+
for (const f of list) {
|
|
1100
|
+
const opt = el('option', null, f.label)
|
|
1101
|
+
opt.value = f.id
|
|
1102
|
+
select.append(opt)
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
const availCjk = TEXT_FONTS_CJK.filter((f) => fontAvailable(f.name)) // 只保留本机已装
|
|
1106
|
+
const availLatin = TEXT_FONTS_LATIN.filter((f) => fontAvailable(f.name))
|
|
1107
|
+
const availCode = CODE_FONTS.filter((f) => fontAvailable(f.name))
|
|
1108
|
+
fillFontSelect(textCjkSelect, availCjk)
|
|
1109
|
+
fillFontSelect(textLatinSelect, availLatin)
|
|
1110
|
+
fillFontSelect(codeFontSelect, availCode)
|
|
1111
|
+
// 已保存的选择若已不在本机(被卸载 / 换机器)→ 回默认
|
|
1112
|
+
let fontsReset = false
|
|
1113
|
+
if (state.textFontCjk && !fontChoice(availCjk, state.textFontCjk)) {
|
|
1114
|
+
state.textFontCjk = null
|
|
1115
|
+
fontsReset = true
|
|
1116
|
+
}
|
|
1117
|
+
if (state.textFontLatin && !fontChoice(availLatin, state.textFontLatin)) {
|
|
1118
|
+
state.textFontLatin = null
|
|
1119
|
+
fontsReset = true
|
|
1120
|
+
}
|
|
1121
|
+
if (state.codeFont && !fontChoice(availCode, state.codeFont)) {
|
|
1122
|
+
state.codeFont = null
|
|
1123
|
+
fontsReset = true
|
|
1124
|
+
}
|
|
1125
|
+
if (fontsReset) writeState({ textFontCjk: state.textFontCjk, textFontLatin: state.textFontLatin, codeFont: state.codeFont })
|
|
1126
|
+
let fontPickBuilt = false
|
|
1127
|
+
/** 生成 / 刷新字体与排版覆盖层;官方主题未就绪时保持停用,等定时器重试。 */
|
|
1128
|
+
function rebuildFontPick() {
|
|
1129
|
+
if (!state.textFontCjk && !state.textFontLatin && !state.codeFont && state.lineHeight == null && state.letterSpacing == null) {
|
|
1130
|
+
fontPickTag.disabled = true
|
|
1131
|
+
fontPickBuilt = false
|
|
1132
|
+
return
|
|
1133
|
+
}
|
|
1134
|
+
if (fontPickBuilt) return
|
|
1135
|
+
const css = buildFontPickCss()
|
|
1136
|
+
if (!css) return
|
|
1137
|
+
fontPickTag.textContent = css
|
|
1138
|
+
fontPickTag.disabled = false
|
|
1139
|
+
fontPickBuilt = true
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
applyFont()
|
|
1143
|
+
renderAll()
|
|
1144
|
+
applyWidthState()
|
|
1145
|
+
rebuildFontPick()
|
|
1146
|
+
// 面板开着启动(上次退出未关):补挂载定位——不经 togglePanel 时面板没有
|
|
1147
|
+
// left/top,会落在文档流静态位置(方向取决于页面流,即错位;实测在左下角)
|
|
1148
|
+
if (state.open) placePanel()
|
|
1149
|
+
|
|
1150
|
+
// ── 拖动 + 点击 ─────────────────────────────────────────────────
|
|
1151
|
+
let drag = null
|
|
1152
|
+
function onPointerDown(e) {
|
|
1153
|
+
if (e.button !== 0) return
|
|
1154
|
+
drag = { sx: e.clientX, sy: e.clientY, ox: fab.offsetLeft, oy: fab.offsetTop, moved: false }
|
|
1155
|
+
fab.setPointerCapture(e.pointerId)
|
|
1156
|
+
e.preventDefault()
|
|
1157
|
+
}
|
|
1158
|
+
function onPointerMove(e) {
|
|
1159
|
+
if (!drag) return
|
|
1160
|
+
const dx = e.clientX - drag.sx
|
|
1161
|
+
const dy = e.clientY - drag.sy
|
|
1162
|
+
if (Math.abs(dx) + Math.abs(dy) > 4) drag.moved = true
|
|
1163
|
+
if (!drag.moved) return
|
|
1164
|
+
const [x, y] = clampPos(drag.ox + dx, drag.oy + dy, FAB_SIZE, FAB_SIZE)
|
|
1165
|
+
fab.style.left = x + 'px'
|
|
1166
|
+
fab.style.top = y + 'px'
|
|
1167
|
+
}
|
|
1168
|
+
function onPointerUp() {
|
|
1169
|
+
if (!drag) return
|
|
1170
|
+
const wasDrag = drag.moved
|
|
1171
|
+
drag = null
|
|
1172
|
+
if (wasDrag) {
|
|
1173
|
+
writeState({ x: fab.offsetLeft, y: fab.offsetTop })
|
|
1174
|
+
if (state.open) placePanel()
|
|
1175
|
+
} else {
|
|
1176
|
+
togglePanel(!state.open)
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
function onPointerCancel() {
|
|
1180
|
+
drag = null
|
|
1181
|
+
}
|
|
1182
|
+
fab.addEventListener('pointerdown', onPointerDown)
|
|
1183
|
+
fab.addEventListener('pointermove', onPointerMove)
|
|
1184
|
+
fab.addEventListener('pointerup', onPointerUp)
|
|
1185
|
+
fab.addEventListener('pointercancel', onPointerCancel)
|
|
1186
|
+
closeBtn.addEventListener('click', () => togglePanel(false))
|
|
1187
|
+
|
|
1188
|
+
// ── 功能 8:面板拖动 / 拉拽高度 ──────────────────────────────────
|
|
1189
|
+
// 拖动取"面板任意空白处"(交互控件与滚动条除外);移动/抬起监听挂 window
|
|
1190
|
+
//(快速拖动不丢);>4px 才算拖动、点一下不误记;双击标题栏复位自动布局。
|
|
1191
|
+
let panelDrag = null
|
|
1192
|
+
let panelResize = null
|
|
1193
|
+
const dragBlocked = (target) =>
|
|
1194
|
+
!!(target && target.closest && target.closest('button, input, select, a, .dsrb-row-click, .dsrb-resize, .dsrb-resize-top'))
|
|
1195
|
+
panel.addEventListener('pointerdown', (e) => {
|
|
1196
|
+
if (e.button !== 0 || e.pointerType === 'touch' || dragBlocked(e.target)) return
|
|
1197
|
+
if (e.target === panelBody && e.offsetX >= panelBody.clientWidth) return // 内容区的滚动条
|
|
1198
|
+
panelDrag = {
|
|
1199
|
+
sx: e.clientX, sy: e.clientY,
|
|
1200
|
+
ox: Number.parseFloat(panel.style.left) || 0,
|
|
1201
|
+
oy: Number.parseFloat(panel.style.top) || 0,
|
|
1202
|
+
moved: false,
|
|
1203
|
+
}
|
|
1204
|
+
e.preventDefault()
|
|
1205
|
+
})
|
|
1206
|
+
const startResize = (fromTop) => (e) => {
|
|
1207
|
+
if (e.button !== 0) return
|
|
1208
|
+
panelResize = {
|
|
1209
|
+
sy: e.clientY,
|
|
1210
|
+
oh: panel.offsetHeight,
|
|
1211
|
+
oy: Number.parseFloat(panel.style.top) || 0,
|
|
1212
|
+
fromTop,
|
|
1213
|
+
moved: false,
|
|
1214
|
+
}
|
|
1215
|
+
e.preventDefault()
|
|
1216
|
+
}
|
|
1217
|
+
resizeTop.addEventListener('pointerdown', startResize(true))
|
|
1218
|
+
resizeBottom.addEventListener('pointerdown', startResize(false))
|
|
1219
|
+
function onPanelPointerMove(e) {
|
|
1220
|
+
if (panelDrag) {
|
|
1221
|
+
const dx = e.clientX - panelDrag.sx
|
|
1222
|
+
const dy = e.clientY - panelDrag.sy
|
|
1223
|
+
if (Math.abs(dx) + Math.abs(dy) > 4) panelDrag.moved = true
|
|
1224
|
+
if (!panelDrag.moved) return
|
|
1225
|
+
const x = Math.min(Math.max(0, panelDrag.ox + dx), Math.max(0, window.innerWidth - panel.offsetWidth))
|
|
1226
|
+
const y = Math.min(Math.max(0, panelDrag.oy + dy), Math.max(0, window.innerHeight - 34))
|
|
1227
|
+
panel.style.left = x + 'px'
|
|
1228
|
+
panel.style.top = y + 'px'
|
|
1229
|
+
} else if (panelResize) {
|
|
1230
|
+
const dy = e.clientY - panelResize.sy
|
|
1231
|
+
if (Math.abs(dy) > 4) panelResize.moved = true
|
|
1232
|
+
if (!panelResize.moved) return
|
|
1233
|
+
if (panelResize.fromTop) {
|
|
1234
|
+
// 顶边拉拽:高度与上缘一起变(下缘保持不动)
|
|
1235
|
+
let h = Math.max(PANEL_MIN_H, Math.min(panelResize.oh - dy, window.innerHeight - 8))
|
|
1236
|
+
let top = panelResize.oy + panelResize.oh - h
|
|
1237
|
+
if (top < 0) {
|
|
1238
|
+
h += top
|
|
1239
|
+
top = 0
|
|
1240
|
+
}
|
|
1241
|
+
panel.style.top = top + 'px'
|
|
1242
|
+
panel.style.height = h + 'px'
|
|
1243
|
+
panel.style.maxHeight = h + 'px'
|
|
1244
|
+
} else {
|
|
1245
|
+
// 底边拉拽:只变高度(上缘不动)
|
|
1246
|
+
const maxH = Math.max(PANEL_MIN_H, window.innerHeight - panel.offsetTop - 8)
|
|
1247
|
+
const h = Math.min(maxH, Math.max(PANEL_MIN_H, panelResize.oh + dy))
|
|
1248
|
+
panel.style.height = h + 'px'
|
|
1249
|
+
panel.style.maxHeight = h + 'px'
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
function onPanelPointerUp() {
|
|
1254
|
+
if (panelDrag) {
|
|
1255
|
+
if (panelDrag.moved) {
|
|
1256
|
+
const x = Number.parseFloat(panel.style.left)
|
|
1257
|
+
const y = Number.parseFloat(panel.style.top)
|
|
1258
|
+
if (Number.isFinite(x) && Number.isFinite(y)) {
|
|
1259
|
+
state.panelX = x
|
|
1260
|
+
state.panelY = y
|
|
1261
|
+
writeState({ panelX: x, panelY: y })
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
panelDrag = null
|
|
1265
|
+
}
|
|
1266
|
+
if (panelResize) {
|
|
1267
|
+
if (panelResize.moved) {
|
|
1268
|
+
const h = Number.parseFloat(panel.style.height)
|
|
1269
|
+
if (Number.isFinite(h)) {
|
|
1270
|
+
const patch = { panelH: h }
|
|
1271
|
+
state.panelH = h
|
|
1272
|
+
if (panelResize.fromTop) {
|
|
1273
|
+
// 顶边拉拽同时改变了位置(上缘),一并落盘,否则重开时会被自动布局覆盖
|
|
1274
|
+
const t = Number.parseFloat(panel.style.top)
|
|
1275
|
+
const x = Number.parseFloat(panel.style.left)
|
|
1276
|
+
if (Number.isFinite(t)) {
|
|
1277
|
+
state.panelY = t
|
|
1278
|
+
patch.panelY = t
|
|
1279
|
+
}
|
|
1280
|
+
if (Number.isFinite(x)) {
|
|
1281
|
+
state.panelX = x
|
|
1282
|
+
patch.panelX = x
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
writeState(patch)
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
panelResize = null
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
window.addEventListener('pointermove', onPanelPointerMove)
|
|
1292
|
+
window.addEventListener('pointerup', onPanelPointerUp)
|
|
1293
|
+
window.addEventListener('pointercancel', onPanelPointerUp)
|
|
1294
|
+
headRow.title = '面板任意空白处可拖动 · 双击标题栏复位'
|
|
1295
|
+
headRow.addEventListener('dblclick', () => {
|
|
1296
|
+
// 复位:回到自动布局(位置 + 高度一起,回到"高度锁"状态)
|
|
1297
|
+
state.panelX = null
|
|
1298
|
+
state.panelY = null
|
|
1299
|
+
state.panelH = null
|
|
1300
|
+
writeState({ panelX: null, panelY: null, panelH: null })
|
|
1301
|
+
placePanel()
|
|
1302
|
+
})
|
|
1303
|
+
|
|
1304
|
+
// ── 功能 9:复制为 Markdown(标准库实现)—— 选区在消息行内时:标签映射交给
|
|
1305
|
+
// vendored turndown + 官方 gfm 插件(表格/删除线/任务列表;均 MIT、逐字内联见文件尾);
|
|
1306
|
+
// 公式由 KaTeX 官方同源的 annotation 源码出口还原(行内 $…$ / 独立 $$…$$)──
|
|
1307
|
+
function findTexSource(root) {
|
|
1308
|
+
const walk = (node) => {
|
|
1309
|
+
for (const k of node.childNodes) {
|
|
1310
|
+
// tagName 大小写随命名空间而变(HTML 大写 / MathML 小写),统一按大写比较
|
|
1311
|
+
if (String(k.tagName).toUpperCase() === 'ANNOTATION' && k.getAttribute('encoding') === 'application/x-tex') {
|
|
1312
|
+
return (k.textContent || '').trim()
|
|
1313
|
+
}
|
|
1314
|
+
const hit = walk(k)
|
|
1315
|
+
if (hit) return hit
|
|
1316
|
+
}
|
|
1317
|
+
return ''
|
|
1318
|
+
}
|
|
1319
|
+
return walk(root)
|
|
1320
|
+
}
|
|
1321
|
+
let turndownSvc = null
|
|
1322
|
+
/** 懒建并复用 turndown 实例(标准库负责标签映射;仅补一条公式规则——
|
|
1323
|
+
* .katex / .katex-display 由 KaTeX 的 annotation 源码出口还原)。 */
|
|
1324
|
+
function getTurndown() {
|
|
1325
|
+
if (turndownSvc) return turndownSvc
|
|
1326
|
+
turndownSvc = new TurndownService({ codeBlockStyle: 'fenced', headingStyle: 'atx', bulletListMarker: '-' })
|
|
1327
|
+
turndownSvc.use(turndownPluginGfm.gfm) // GFM 扩展:表格 / 删除线 / 任务列表(官方配套插件,见文件尾 vendored 段)
|
|
1328
|
+
turndownSvc.addRule('katex', {
|
|
1329
|
+
filter: (node) => {
|
|
1330
|
+
const cls = String((node && node.className) || '')
|
|
1331
|
+
return /(^|\s)katex(\s|$)/.test(cls) || cls.indexOf('katex-display') !== -1
|
|
1332
|
+
},
|
|
1333
|
+
replacement: (_content, node) => {
|
|
1334
|
+
const tex = findTexSource(node)
|
|
1335
|
+
if (!tex) return ''
|
|
1336
|
+
return String(node.className).indexOf('katex-display') !== -1 ? '\n$$' + tex + '$$\n' : '$' + tex + '$'
|
|
1337
|
+
},
|
|
1338
|
+
})
|
|
1339
|
+
return turndownSvc
|
|
1340
|
+
}
|
|
1341
|
+
function onCopy(e) {
|
|
1342
|
+
try {
|
|
1343
|
+
const sel = document.getSelection()
|
|
1344
|
+
if (!sel || sel.isCollapsed || !sel.rangeCount) return
|
|
1345
|
+
// 选区两端都要在消息行内才接管(跨区选择保持系统默认行为)
|
|
1346
|
+
const inChat = (n) => {
|
|
1347
|
+
const el = n && (n.nodeType === 3 ? n.parentElement : n)
|
|
1348
|
+
return !!(el && el.closest && el.closest('[data-chat-node-key]'))
|
|
1349
|
+
}
|
|
1350
|
+
if (!inChat(sel.anchorNode) || !inChat(sel.focusNode)) return
|
|
1351
|
+
const elOf = (n) => (n && n.nodeType === 3 ? n.parentElement : n)
|
|
1352
|
+
const ka = elOf(sel.anchorNode).closest('.katex')
|
|
1353
|
+
const kf = elOf(sel.focusNode).closest('.katex')
|
|
1354
|
+
// 只选中了某个公式本身(两端落在同一个公式内)→ 直接输出该公式的 LaTeX
|
|
1355
|
+
if (ka && ka === kf) {
|
|
1356
|
+
const tex = findTexSource(ka)
|
|
1357
|
+
if (tex) {
|
|
1358
|
+
e.clipboardData.setData('text/plain', ka.closest('.katex-display') ? '$$' + tex + '$$' : '$' + tex + '$')
|
|
1359
|
+
e.clipboardData.setData('text/html', ka.outerHTML) // 富目标(Word 等)粘贴仍显示公式
|
|
1360
|
+
e.preventDefault()
|
|
1361
|
+
return
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
// 与 KaTeX 官方 copy-tex 同策略:端点落在公式内时先把工作范围扩到整个公式,
|
|
1365
|
+
// "只碰一角"也能拿到完整 LaTeX(只影响复制内容,不动真实选区)
|
|
1366
|
+
const work = sel.getRangeAt(0).cloneRange()
|
|
1367
|
+
if (ka) work.setStartBefore(ka)
|
|
1368
|
+
if (kf) work.setEndAfter(kf)
|
|
1369
|
+
const box = document.createElement('div')
|
|
1370
|
+
box.append(work.cloneContents())
|
|
1371
|
+
// 公式场景先留存原始 HTML(含 <math>):粘进 Word 等富目标仍可显示公式
|
|
1372
|
+
const html = box.querySelector('.katex') ? box.innerHTML : null
|
|
1373
|
+
// 标签→Markdown 映射由标准库 turndown 承担(公式已由上面的 katex 规则还原为 LaTeX)
|
|
1374
|
+
const md = getTurndown().turndown(box).replace(/\n{3,}/g, '\n\n').trim()
|
|
1375
|
+
if (!md) return
|
|
1376
|
+
e.clipboardData.setData('text/plain', md)
|
|
1377
|
+
if (html) e.clipboardData.setData('text/html', html)
|
|
1378
|
+
e.preventDefault()
|
|
1379
|
+
} catch (err) {
|
|
1380
|
+
// 任何异常都不阻断这次复制(退回浏览器默认行为)
|
|
1381
|
+
console.warn('[dsh-happy-reader] 复制处理失败:', err)
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
document.addEventListener('copy', onCopy, true)
|
|
1385
|
+
|
|
1386
|
+
function onResize() {
|
|
1387
|
+
const [x, y] = clampPos(fab.offsetLeft, fab.offsetTop, FAB_SIZE, FAB_SIZE)
|
|
1388
|
+
fab.style.left = x + 'px'
|
|
1389
|
+
fab.style.top = y + 'px'
|
|
1390
|
+
normalizeWidthDrift()
|
|
1391
|
+
if (state.open) {
|
|
1392
|
+
renderWidthRow()
|
|
1393
|
+
placePanel()
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
window.addEventListener('resize', onResize)
|
|
1397
|
+
|
|
1398
|
+
// 会话 DOM 迟现/被替换 → 轻量定时器持续核对(同节点零开销),顺带漂移兜底与
|
|
1399
|
+
// 占位符刷新;列宽突变由 columnObserver 即时处理,定时器只兜底重挂。
|
|
1400
|
+
attachWidthObserver()
|
|
1401
|
+
attachColumnObserver()
|
|
1402
|
+
const retryTimer = setInterval(() => {
|
|
1403
|
+
attachWidthObserver()
|
|
1404
|
+
attachColumnObserver()
|
|
1405
|
+
normalizeWidthDrift()
|
|
1406
|
+
rebuildFontPick() // 官方主题样式稍后就绪时补建覆盖层(已建则零开销早退)
|
|
1407
|
+
if (state.open && !officialDragging && !widthDragging) renderWidthRow()
|
|
1408
|
+
}, 2000)
|
|
1409
|
+
|
|
1410
|
+
// 启动标记:记录加载时刻与版本(供诊断确认插件已加载;不影响功能)
|
|
1411
|
+
writeState({ lastBootAt: Date.now(), lastBootVersion: VERSION })
|
|
1412
|
+
console.log('[dsh-happy-reader] 客户端半边已加载 v' + VERSION)
|
|
1413
|
+
|
|
1414
|
+
// ── 卸载清理(官方 ctx.effect 机制自动执行;同一函数登记到 window.__dsrbDispose
|
|
1415
|
+
// 供热重载重建前先拆旧实例;幂等,可重复调用) ──
|
|
1416
|
+
const dispose = () => {
|
|
1417
|
+
clearInterval(retryTimer)
|
|
1418
|
+
widthObserver.disconnect()
|
|
1419
|
+
columnObserver.disconnect()
|
|
1420
|
+
window.removeEventListener('resize', onResize)
|
|
1421
|
+
window.removeEventListener('pointerup', onWindowPointerUp)
|
|
1422
|
+
window.removeEventListener('pointermove', onPanelPointerMove)
|
|
1423
|
+
window.removeEventListener('pointerup', onPanelPointerUp)
|
|
1424
|
+
window.removeEventListener('pointercancel', onPanelPointerUp)
|
|
1425
|
+
document.removeEventListener('pointerdown', onDocPointerDown, true)
|
|
1426
|
+
document.removeEventListener('pointermove', onDocPointerMove, true)
|
|
1427
|
+
document.removeEventListener('pointerup', onDocPointerUp, true)
|
|
1428
|
+
document.removeEventListener('pointercancel', onDocPointerCancel, true)
|
|
1429
|
+
document.removeEventListener('copy', onCopy, true)
|
|
1430
|
+
fab.remove()
|
|
1431
|
+
panel.remove()
|
|
1432
|
+
style.remove()
|
|
1433
|
+
hideTag.remove()
|
|
1434
|
+
hideTopTag.remove()
|
|
1435
|
+
fontTag.remove()
|
|
1436
|
+
fontPickTag.remove()
|
|
1437
|
+
fsTag.remove()
|
|
1438
|
+
document.removeEventListener('fullscreenchange', syncFullscreen)
|
|
1439
|
+
document.documentElement.removeAttribute(FS_ATTR)
|
|
1440
|
+
document.documentElement.style.removeProperty('--dsrb-font-base')
|
|
1441
|
+
document.documentElement.style.removeProperty('--dsrb-font-scale')
|
|
1442
|
+
const t = widthTargets()
|
|
1443
|
+
if (t) t.container.style.removeProperty('--dsh-chat-content-width')
|
|
1444
|
+
}
|
|
1445
|
+
ctx.effect(() => dispose, 'dsh-happy-reader: 实体与监听')
|
|
1446
|
+
window.__dsrbDispose = dispose
|
|
1447
|
+
} catch (err) {
|
|
1448
|
+
// 任何意外都只记日志,不影响应用本体
|
|
1449
|
+
console.error('[dsh-happy-reader] 挂载失败:', err)
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
// ══ vendored: turndown start ══
|
|
1454
|
+
// turndown 7.2.4(MIT License)——逐字内联自官方发行版 dist/turndown.js。
|
|
1455
|
+
// 用途:选区复制时把渲染后的 HTML 按社区标准规则映射回 Markdown(不再是手写标签规则)。
|
|
1456
|
+
// 官方仓库:https://github.com/mixmark-io/turndown
|
|
1457
|
+
/*
|
|
1458
|
+
MIT License
|
|
1459
|
+
|
|
1460
|
+
Copyright (c) 2017 Dom Christie
|
|
1461
|
+
|
|
1462
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
1463
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
1464
|
+
in the Software without restriction, including without limitation the rights
|
|
1465
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
1466
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
1467
|
+
furnished to do so, subject to the following conditions:
|
|
1468
|
+
|
|
1469
|
+
The above copyright notice and this permission notice shall be included in all
|
|
1470
|
+
copies or substantial portions of the Software.
|
|
1471
|
+
|
|
1472
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
1473
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
1474
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
1475
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
1476
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
1477
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
1478
|
+
SOFTWARE.
|
|
1479
|
+
*/
|
|
1480
|
+
var TurndownService = (function () {
|
|
1481
|
+
'use strict';
|
|
1482
|
+
|
|
1483
|
+
function extend(destination) {
|
|
1484
|
+
for (var i = 1; i < arguments.length; i++) {
|
|
1485
|
+
var source = arguments[i];
|
|
1486
|
+
for (var key in source) {
|
|
1487
|
+
if (Object.prototype.hasOwnProperty.call(source, key)) destination[key] = source[key];
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
return destination;
|
|
1491
|
+
}
|
|
1492
|
+
function repeat(character, count) {
|
|
1493
|
+
return Array(count + 1).join(character);
|
|
1494
|
+
}
|
|
1495
|
+
function trimLeadingNewlines(string) {
|
|
1496
|
+
return string.replace(/^\n*/, '');
|
|
1497
|
+
}
|
|
1498
|
+
function trimTrailingNewlines(string) {
|
|
1499
|
+
// avoid match-at-end regexp bottleneck, see #370
|
|
1500
|
+
var indexEnd = string.length;
|
|
1501
|
+
while (indexEnd > 0 && string[indexEnd - 1] === '\n') indexEnd--;
|
|
1502
|
+
return string.substring(0, indexEnd);
|
|
1503
|
+
}
|
|
1504
|
+
function trimNewlines(string) {
|
|
1505
|
+
return trimTrailingNewlines(trimLeadingNewlines(string));
|
|
1506
|
+
}
|
|
1507
|
+
var blockElements = ['ADDRESS', 'ARTICLE', 'ASIDE', 'AUDIO', 'BLOCKQUOTE', 'BODY', 'CANVAS', 'CENTER', 'DD', 'DIR', 'DIV', 'DL', 'DT', 'FIELDSET', 'FIGCAPTION', 'FIGURE', 'FOOTER', 'FORM', 'FRAMESET', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'HEADER', 'HGROUP', 'HR', 'HTML', 'ISINDEX', 'LI', 'MAIN', 'MENU', 'NAV', 'NOFRAMES', 'NOSCRIPT', 'OL', 'OUTPUT', 'P', 'PRE', 'SECTION', 'TABLE', 'TBODY', 'TD', 'TFOOT', 'TH', 'THEAD', 'TR', 'UL'];
|
|
1508
|
+
function isBlock(node) {
|
|
1509
|
+
return is(node, blockElements);
|
|
1510
|
+
}
|
|
1511
|
+
var voidElements = ['AREA', 'BASE', 'BR', 'COL', 'COMMAND', 'EMBED', 'HR', 'IMG', 'INPUT', 'KEYGEN', 'LINK', 'META', 'PARAM', 'SOURCE', 'TRACK', 'WBR'];
|
|
1512
|
+
function isVoid(node) {
|
|
1513
|
+
return is(node, voidElements);
|
|
1514
|
+
}
|
|
1515
|
+
function hasVoid(node) {
|
|
1516
|
+
return has(node, voidElements);
|
|
1517
|
+
}
|
|
1518
|
+
var meaningfulWhenBlankElements = ['A', 'TABLE', 'THEAD', 'TBODY', 'TFOOT', 'TH', 'TD', 'IFRAME', 'SCRIPT', 'AUDIO', 'VIDEO'];
|
|
1519
|
+
function isMeaningfulWhenBlank(node) {
|
|
1520
|
+
return is(node, meaningfulWhenBlankElements);
|
|
1521
|
+
}
|
|
1522
|
+
function hasMeaningfulWhenBlank(node) {
|
|
1523
|
+
return has(node, meaningfulWhenBlankElements);
|
|
1524
|
+
}
|
|
1525
|
+
function is(node, tagNames) {
|
|
1526
|
+
return tagNames.indexOf(node.nodeName) >= 0;
|
|
1527
|
+
}
|
|
1528
|
+
function has(node, tagNames) {
|
|
1529
|
+
return node.getElementsByTagName && tagNames.some(function (tagName) {
|
|
1530
|
+
return node.getElementsByTagName(tagName).length;
|
|
1531
|
+
});
|
|
1532
|
+
}
|
|
1533
|
+
var markdownEscapes = [[/\\/g, '\\\\'], [/\*/g, '\\*'], [/^-/g, '\\-'], [/^\+ /g, '\\+ '], [/^(=+)/g, '\\$1'], [/^(#{1,6}) /g, '\\$1 '], [/`/g, '\\`'], [/^~~~/g, '\\~~~'], [/\[/g, '\\['], [/\]/g, '\\]'], [/^>/g, '\\>'], [/_/g, '\\_'], [/^(\d+)\. /g, '$1\\. ']];
|
|
1534
|
+
function escapeMarkdown(string) {
|
|
1535
|
+
return markdownEscapes.reduce(function (accumulator, escape) {
|
|
1536
|
+
return accumulator.replace(escape[0], escape[1]);
|
|
1537
|
+
}, string);
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
var rules = {};
|
|
1541
|
+
rules.paragraph = {
|
|
1542
|
+
filter: 'p',
|
|
1543
|
+
replacement: function (content) {
|
|
1544
|
+
return '\n\n' + content + '\n\n';
|
|
1545
|
+
}
|
|
1546
|
+
};
|
|
1547
|
+
rules.lineBreak = {
|
|
1548
|
+
filter: 'br',
|
|
1549
|
+
replacement: function (content, node, options) {
|
|
1550
|
+
return options.br + '\n';
|
|
1551
|
+
}
|
|
1552
|
+
};
|
|
1553
|
+
rules.heading = {
|
|
1554
|
+
filter: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'],
|
|
1555
|
+
replacement: function (content, node, options) {
|
|
1556
|
+
var hLevel = Number(node.nodeName.charAt(1));
|
|
1557
|
+
if (options.headingStyle === 'setext' && hLevel < 3) {
|
|
1558
|
+
var underline = repeat(hLevel === 1 ? '=' : '-', content.length);
|
|
1559
|
+
return '\n\n' + content + '\n' + underline + '\n\n';
|
|
1560
|
+
} else {
|
|
1561
|
+
return '\n\n' + repeat('#', hLevel) + ' ' + content + '\n\n';
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
1564
|
+
};
|
|
1565
|
+
rules.blockquote = {
|
|
1566
|
+
filter: 'blockquote',
|
|
1567
|
+
replacement: function (content) {
|
|
1568
|
+
content = trimNewlines(content).replace(/^/gm, '> ');
|
|
1569
|
+
return '\n\n' + content + '\n\n';
|
|
1570
|
+
}
|
|
1571
|
+
};
|
|
1572
|
+
rules.list = {
|
|
1573
|
+
filter: ['ul', 'ol'],
|
|
1574
|
+
replacement: function (content, node) {
|
|
1575
|
+
var parent = node.parentNode;
|
|
1576
|
+
if (parent.nodeName === 'LI' && parent.lastElementChild === node) {
|
|
1577
|
+
return '\n' + content;
|
|
1578
|
+
} else {
|
|
1579
|
+
return '\n\n' + content + '\n\n';
|
|
1580
|
+
}
|
|
1581
|
+
}
|
|
1582
|
+
};
|
|
1583
|
+
rules.listItem = {
|
|
1584
|
+
filter: 'li',
|
|
1585
|
+
replacement: function (content, node, options) {
|
|
1586
|
+
var prefix = options.bulletListMarker + ' ';
|
|
1587
|
+
var parent = node.parentNode;
|
|
1588
|
+
if (parent.nodeName === 'OL') {
|
|
1589
|
+
var start = parent.getAttribute('start');
|
|
1590
|
+
var index = Array.prototype.indexOf.call(parent.children, node);
|
|
1591
|
+
prefix = (start ? Number(start) + index : index + 1) + '. ';
|
|
1592
|
+
}
|
|
1593
|
+
var isParagraph = /\n$/.test(content);
|
|
1594
|
+
content = trimNewlines(content) + (isParagraph ? '\n' : '');
|
|
1595
|
+
content = content.replace(/\n/gm, '\n' + ' '.repeat(prefix.length)); // indent
|
|
1596
|
+
return prefix + content + (node.nextSibling ? '\n' : '');
|
|
1597
|
+
}
|
|
1598
|
+
};
|
|
1599
|
+
rules.indentedCodeBlock = {
|
|
1600
|
+
filter: function (node, options) {
|
|
1601
|
+
return options.codeBlockStyle === 'indented' && node.nodeName === 'PRE' && node.firstChild && node.firstChild.nodeName === 'CODE';
|
|
1602
|
+
},
|
|
1603
|
+
replacement: function (content, node, options) {
|
|
1604
|
+
return '\n\n ' + node.firstChild.textContent.replace(/\n/g, '\n ') + '\n\n';
|
|
1605
|
+
}
|
|
1606
|
+
};
|
|
1607
|
+
rules.fencedCodeBlock = {
|
|
1608
|
+
filter: function (node, options) {
|
|
1609
|
+
return options.codeBlockStyle === 'fenced' && node.nodeName === 'PRE' && node.firstChild && node.firstChild.nodeName === 'CODE';
|
|
1610
|
+
},
|
|
1611
|
+
replacement: function (content, node, options) {
|
|
1612
|
+
var className = node.firstChild.getAttribute('class') || '';
|
|
1613
|
+
var language = (className.match(/language-(\S+)/) || [null, ''])[1];
|
|
1614
|
+
var code = node.firstChild.textContent;
|
|
1615
|
+
var fenceChar = options.fence.charAt(0);
|
|
1616
|
+
var fenceSize = 3;
|
|
1617
|
+
var fenceInCodeRegex = new RegExp('^' + fenceChar + '{3,}', 'gm');
|
|
1618
|
+
var match;
|
|
1619
|
+
while (match = fenceInCodeRegex.exec(code)) {
|
|
1620
|
+
if (match[0].length >= fenceSize) {
|
|
1621
|
+
fenceSize = match[0].length + 1;
|
|
1622
|
+
}
|
|
1623
|
+
}
|
|
1624
|
+
var fence = repeat(fenceChar, fenceSize);
|
|
1625
|
+
return '\n\n' + fence + language + '\n' + code.replace(/\n$/, '') + '\n' + fence + '\n\n';
|
|
1626
|
+
}
|
|
1627
|
+
};
|
|
1628
|
+
rules.horizontalRule = {
|
|
1629
|
+
filter: 'hr',
|
|
1630
|
+
replacement: function (content, node, options) {
|
|
1631
|
+
return '\n\n' + options.hr + '\n\n';
|
|
1632
|
+
}
|
|
1633
|
+
};
|
|
1634
|
+
rules.inlineLink = {
|
|
1635
|
+
filter: function (node, options) {
|
|
1636
|
+
return options.linkStyle === 'inlined' && node.nodeName === 'A' && node.getAttribute('href');
|
|
1637
|
+
},
|
|
1638
|
+
replacement: function (content, node) {
|
|
1639
|
+
var href = escapeLinkDestination(node.getAttribute('href'));
|
|
1640
|
+
var title = escapeLinkTitle(cleanAttribute(node.getAttribute('title')));
|
|
1641
|
+
var titlePart = title ? ' "' + title + '"' : '';
|
|
1642
|
+
return '[' + content + '](' + href + titlePart + ')';
|
|
1643
|
+
}
|
|
1644
|
+
};
|
|
1645
|
+
rules.referenceLink = {
|
|
1646
|
+
filter: function (node, options) {
|
|
1647
|
+
return options.linkStyle === 'referenced' && node.nodeName === 'A' && node.getAttribute('href');
|
|
1648
|
+
},
|
|
1649
|
+
replacement: function (content, node, options) {
|
|
1650
|
+
var href = escapeLinkDestination(node.getAttribute('href'));
|
|
1651
|
+
var title = cleanAttribute(node.getAttribute('title'));
|
|
1652
|
+
if (title) title = ' "' + escapeLinkTitle(title) + '"';
|
|
1653
|
+
var replacement;
|
|
1654
|
+
var reference;
|
|
1655
|
+
switch (options.linkReferenceStyle) {
|
|
1656
|
+
case 'collapsed':
|
|
1657
|
+
replacement = '[' + content + '][]';
|
|
1658
|
+
reference = '[' + content + ']: ' + href + title;
|
|
1659
|
+
break;
|
|
1660
|
+
case 'shortcut':
|
|
1661
|
+
replacement = '[' + content + ']';
|
|
1662
|
+
reference = '[' + content + ']: ' + href + title;
|
|
1663
|
+
break;
|
|
1664
|
+
default:
|
|
1665
|
+
var id = this.references.length + 1;
|
|
1666
|
+
replacement = '[' + content + '][' + id + ']';
|
|
1667
|
+
reference = '[' + id + ']: ' + href + title;
|
|
1668
|
+
}
|
|
1669
|
+
this.references.push(reference);
|
|
1670
|
+
return replacement;
|
|
1671
|
+
},
|
|
1672
|
+
references: [],
|
|
1673
|
+
append: function (options) {
|
|
1674
|
+
var references = '';
|
|
1675
|
+
if (this.references.length) {
|
|
1676
|
+
references = '\n\n' + this.references.join('\n') + '\n\n';
|
|
1677
|
+
this.references = []; // Reset references
|
|
1678
|
+
}
|
|
1679
|
+
return references;
|
|
1680
|
+
}
|
|
1681
|
+
};
|
|
1682
|
+
rules.emphasis = {
|
|
1683
|
+
filter: ['em', 'i'],
|
|
1684
|
+
replacement: function (content, node, options) {
|
|
1685
|
+
if (!content.trim()) return '';
|
|
1686
|
+
return options.emDelimiter + content + options.emDelimiter;
|
|
1687
|
+
}
|
|
1688
|
+
};
|
|
1689
|
+
rules.strong = {
|
|
1690
|
+
filter: ['strong', 'b'],
|
|
1691
|
+
replacement: function (content, node, options) {
|
|
1692
|
+
if (!content.trim()) return '';
|
|
1693
|
+
return options.strongDelimiter + content + options.strongDelimiter;
|
|
1694
|
+
}
|
|
1695
|
+
};
|
|
1696
|
+
rules.code = {
|
|
1697
|
+
filter: function (node) {
|
|
1698
|
+
var hasSiblings = node.previousSibling || node.nextSibling;
|
|
1699
|
+
var isCodeBlock = node.parentNode.nodeName === 'PRE' && !hasSiblings;
|
|
1700
|
+
return node.nodeName === 'CODE' && !isCodeBlock;
|
|
1701
|
+
},
|
|
1702
|
+
replacement: function (content) {
|
|
1703
|
+
if (!content) return '';
|
|
1704
|
+
content = content.replace(/\r?\n|\r/g, ' ');
|
|
1705
|
+
var extraSpace = /^`|^ .*?[^ ].* $|`$/.test(content) ? ' ' : '';
|
|
1706
|
+
var delimiter = '`';
|
|
1707
|
+
var matches = content.match(/`+/gm) || [];
|
|
1708
|
+
while (matches.indexOf(delimiter) !== -1) delimiter = delimiter + '`';
|
|
1709
|
+
return delimiter + extraSpace + content + extraSpace + delimiter;
|
|
1710
|
+
}
|
|
1711
|
+
};
|
|
1712
|
+
rules.image = {
|
|
1713
|
+
filter: 'img',
|
|
1714
|
+
replacement: function (content, node) {
|
|
1715
|
+
var alt = escapeMarkdown(cleanAttribute(node.getAttribute('alt')));
|
|
1716
|
+
var src = escapeLinkDestination(node.getAttribute('src') || '');
|
|
1717
|
+
var title = cleanAttribute(node.getAttribute('title'));
|
|
1718
|
+
var titlePart = title ? ' "' + escapeLinkTitle(title) + '"' : '';
|
|
1719
|
+
return src ? '![' + alt + ']' + '(' + src + titlePart + ')' : '';
|
|
1720
|
+
}
|
|
1721
|
+
};
|
|
1722
|
+
function cleanAttribute(attribute) {
|
|
1723
|
+
return attribute ? attribute.replace(/(\n+\s*)+/g, '\n') : '';
|
|
1724
|
+
}
|
|
1725
|
+
function escapeLinkDestination(destination) {
|
|
1726
|
+
var escaped = destination.replace(/([<>()])/g, '\\$1');
|
|
1727
|
+
return escaped.indexOf(' ') >= 0 ? '<' + escaped + '>' : escaped;
|
|
1728
|
+
}
|
|
1729
|
+
function escapeLinkTitle(title) {
|
|
1730
|
+
return title.replace(/"/g, '\\"');
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1733
|
+
/**
|
|
1734
|
+
* Manages a collection of rules used to convert HTML to Markdown
|
|
1735
|
+
*/
|
|
1736
|
+
|
|
1737
|
+
function Rules(options) {
|
|
1738
|
+
this.options = options;
|
|
1739
|
+
this._keep = [];
|
|
1740
|
+
this._remove = [];
|
|
1741
|
+
this.blankRule = {
|
|
1742
|
+
replacement: options.blankReplacement
|
|
1743
|
+
};
|
|
1744
|
+
this.keepReplacement = options.keepReplacement;
|
|
1745
|
+
this.defaultRule = {
|
|
1746
|
+
replacement: options.defaultReplacement
|
|
1747
|
+
};
|
|
1748
|
+
this.array = [];
|
|
1749
|
+
for (var key in options.rules) this.array.push(options.rules[key]);
|
|
1750
|
+
}
|
|
1751
|
+
Rules.prototype = {
|
|
1752
|
+
add: function (key, rule) {
|
|
1753
|
+
this.array.unshift(rule);
|
|
1754
|
+
},
|
|
1755
|
+
keep: function (filter) {
|
|
1756
|
+
this._keep.unshift({
|
|
1757
|
+
filter: filter,
|
|
1758
|
+
replacement: this.keepReplacement
|
|
1759
|
+
});
|
|
1760
|
+
},
|
|
1761
|
+
remove: function (filter) {
|
|
1762
|
+
this._remove.unshift({
|
|
1763
|
+
filter: filter,
|
|
1764
|
+
replacement: function () {
|
|
1765
|
+
return '';
|
|
1766
|
+
}
|
|
1767
|
+
});
|
|
1768
|
+
},
|
|
1769
|
+
forNode: function (node) {
|
|
1770
|
+
if (node.isBlank) return this.blankRule;
|
|
1771
|
+
var rule;
|
|
1772
|
+
if (rule = findRule(this.array, node, this.options)) return rule;
|
|
1773
|
+
if (rule = findRule(this._keep, node, this.options)) return rule;
|
|
1774
|
+
if (rule = findRule(this._remove, node, this.options)) return rule;
|
|
1775
|
+
return this.defaultRule;
|
|
1776
|
+
},
|
|
1777
|
+
forEach: function (fn) {
|
|
1778
|
+
for (var i = 0; i < this.array.length; i++) fn(this.array[i], i);
|
|
1779
|
+
}
|
|
1780
|
+
};
|
|
1781
|
+
function findRule(rules, node, options) {
|
|
1782
|
+
for (var i = 0; i < rules.length; i++) {
|
|
1783
|
+
var rule = rules[i];
|
|
1784
|
+
if (filterValue(rule, node, options)) return rule;
|
|
1785
|
+
}
|
|
1786
|
+
return undefined;
|
|
1787
|
+
}
|
|
1788
|
+
function filterValue(rule, node, options) {
|
|
1789
|
+
var filter = rule.filter;
|
|
1790
|
+
if (typeof filter === 'string') {
|
|
1791
|
+
if (filter === node.nodeName.toLowerCase()) return true;
|
|
1792
|
+
} else if (Array.isArray(filter)) {
|
|
1793
|
+
if (filter.indexOf(node.nodeName.toLowerCase()) > -1) return true;
|
|
1794
|
+
} else if (typeof filter === 'function') {
|
|
1795
|
+
if (filter.call(rule, node, options)) return true;
|
|
1796
|
+
} else {
|
|
1797
|
+
throw new TypeError('`filter` needs to be a string, array, or function');
|
|
1798
|
+
}
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
/**
|
|
1802
|
+
* The collapseWhitespace function is adapted from collapse-whitespace
|
|
1803
|
+
* by Luc Thevenard.
|
|
1804
|
+
*
|
|
1805
|
+
* The MIT License (MIT)
|
|
1806
|
+
*
|
|
1807
|
+
* Copyright (c) 2014 Luc Thevenard <lucthevenard@gmail.com>
|
|
1808
|
+
*
|
|
1809
|
+
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
1810
|
+
* of this software and associated documentation files (the "Software"), to deal
|
|
1811
|
+
* in the Software without restriction, including without limitation the rights
|
|
1812
|
+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
1813
|
+
* copies of the Software, and to permit persons to whom the Software is
|
|
1814
|
+
* furnished to do so, subject to the following conditions:
|
|
1815
|
+
*
|
|
1816
|
+
* The above copyright notice and this permission notice shall be included in
|
|
1817
|
+
* all copies or substantial portions of the Software.
|
|
1818
|
+
*
|
|
1819
|
+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
1820
|
+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
1821
|
+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
1822
|
+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
1823
|
+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
1824
|
+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
1825
|
+
* THE SOFTWARE.
|
|
1826
|
+
*/
|
|
1827
|
+
|
|
1828
|
+
/**
|
|
1829
|
+
* collapseWhitespace(options) removes extraneous whitespace from an the given element.
|
|
1830
|
+
*
|
|
1831
|
+
* @param {Object} options
|
|
1832
|
+
*/
|
|
1833
|
+
function collapseWhitespace(options) {
|
|
1834
|
+
var element = options.element;
|
|
1835
|
+
var isBlock = options.isBlock;
|
|
1836
|
+
var isVoid = options.isVoid;
|
|
1837
|
+
var isPre = options.isPre || function (node) {
|
|
1838
|
+
return node.nodeName === 'PRE';
|
|
1839
|
+
};
|
|
1840
|
+
if (!element.firstChild || isPre(element)) return;
|
|
1841
|
+
var prevText = null;
|
|
1842
|
+
var keepLeadingWs = false;
|
|
1843
|
+
var prev = null;
|
|
1844
|
+
var node = next(prev, element, isPre);
|
|
1845
|
+
while (node !== element) {
|
|
1846
|
+
if (node.nodeType === 3 || node.nodeType === 4) {
|
|
1847
|
+
// Node.TEXT_NODE or Node.CDATA_SECTION_NODE
|
|
1848
|
+
var text = node.data.replace(/[ \r\n\t]+/g, ' ');
|
|
1849
|
+
if ((!prevText || / $/.test(prevText.data)) && !keepLeadingWs && text[0] === ' ') {
|
|
1850
|
+
text = text.substr(1);
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
// `text` might be empty at this point.
|
|
1854
|
+
if (!text) {
|
|
1855
|
+
node = remove(node);
|
|
1856
|
+
continue;
|
|
1857
|
+
}
|
|
1858
|
+
node.data = text;
|
|
1859
|
+
prevText = node;
|
|
1860
|
+
} else if (node.nodeType === 1) {
|
|
1861
|
+
// Node.ELEMENT_NODE
|
|
1862
|
+
if (isBlock(node) || node.nodeName === 'BR') {
|
|
1863
|
+
if (prevText) {
|
|
1864
|
+
prevText.data = prevText.data.replace(/ $/, '');
|
|
1865
|
+
}
|
|
1866
|
+
prevText = null;
|
|
1867
|
+
keepLeadingWs = false;
|
|
1868
|
+
} else if (isVoid(node) || isPre(node)) {
|
|
1869
|
+
// Avoid trimming space around non-block, non-BR void elements and inline PRE.
|
|
1870
|
+
prevText = null;
|
|
1871
|
+
keepLeadingWs = true;
|
|
1872
|
+
} else if (prevText) {
|
|
1873
|
+
// Drop protection if set previously.
|
|
1874
|
+
keepLeadingWs = false;
|
|
1875
|
+
}
|
|
1876
|
+
} else {
|
|
1877
|
+
node = remove(node);
|
|
1878
|
+
continue;
|
|
1879
|
+
}
|
|
1880
|
+
var nextNode = next(prev, node, isPre);
|
|
1881
|
+
prev = node;
|
|
1882
|
+
node = nextNode;
|
|
1883
|
+
}
|
|
1884
|
+
if (prevText) {
|
|
1885
|
+
prevText.data = prevText.data.replace(/ $/, '');
|
|
1886
|
+
if (!prevText.data) {
|
|
1887
|
+
remove(prevText);
|
|
1888
|
+
}
|
|
1889
|
+
}
|
|
1890
|
+
}
|
|
1891
|
+
|
|
1892
|
+
/**
|
|
1893
|
+
* remove(node) removes the given node from the DOM and returns the
|
|
1894
|
+
* next node in the sequence.
|
|
1895
|
+
*
|
|
1896
|
+
* @param {Node} node
|
|
1897
|
+
* @return {Node} node
|
|
1898
|
+
*/
|
|
1899
|
+
function remove(node) {
|
|
1900
|
+
var next = node.nextSibling || node.parentNode;
|
|
1901
|
+
node.parentNode.removeChild(node);
|
|
1902
|
+
return next;
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1905
|
+
/**
|
|
1906
|
+
* next(prev, current, isPre) returns the next node in the sequence, given the
|
|
1907
|
+
* current and previous nodes.
|
|
1908
|
+
*
|
|
1909
|
+
* @param {Node} prev
|
|
1910
|
+
* @param {Node} current
|
|
1911
|
+
* @param {Function} isPre
|
|
1912
|
+
* @return {Node}
|
|
1913
|
+
*/
|
|
1914
|
+
function next(prev, current, isPre) {
|
|
1915
|
+
if (prev && prev.parentNode === current || isPre(current)) {
|
|
1916
|
+
return current.nextSibling || current.parentNode;
|
|
1917
|
+
}
|
|
1918
|
+
return current.firstChild || current.nextSibling || current.parentNode;
|
|
1919
|
+
}
|
|
1920
|
+
|
|
1921
|
+
/*
|
|
1922
|
+
* Set up window for Node.js
|
|
1923
|
+
*/
|
|
1924
|
+
|
|
1925
|
+
var root = typeof window !== 'undefined' ? window : {};
|
|
1926
|
+
|
|
1927
|
+
/*
|
|
1928
|
+
* Parsing HTML strings
|
|
1929
|
+
*/
|
|
1930
|
+
|
|
1931
|
+
function canParseHTMLNatively() {
|
|
1932
|
+
var Parser = root.DOMParser;
|
|
1933
|
+
var canParse = false;
|
|
1934
|
+
|
|
1935
|
+
// Adapted from https://gist.github.com/1129031
|
|
1936
|
+
// Firefox/Opera/IE throw errors on unsupported types
|
|
1937
|
+
try {
|
|
1938
|
+
// WebKit returns null on unsupported types
|
|
1939
|
+
if (new Parser().parseFromString('', 'text/html')) {
|
|
1940
|
+
canParse = true;
|
|
1941
|
+
}
|
|
1942
|
+
} catch (e) {}
|
|
1943
|
+
return canParse;
|
|
1944
|
+
}
|
|
1945
|
+
function createHTMLParser() {
|
|
1946
|
+
var Parser = function () {};
|
|
1947
|
+
{
|
|
1948
|
+
if (shouldUseActiveX()) {
|
|
1949
|
+
Parser.prototype.parseFromString = function (string) {
|
|
1950
|
+
var doc = new window.ActiveXObject('htmlfile');
|
|
1951
|
+
doc.designMode = 'on'; // disable on-page scripts
|
|
1952
|
+
doc.open();
|
|
1953
|
+
doc.write(string);
|
|
1954
|
+
doc.close();
|
|
1955
|
+
return doc;
|
|
1956
|
+
};
|
|
1957
|
+
} else {
|
|
1958
|
+
Parser.prototype.parseFromString = function (string) {
|
|
1959
|
+
var doc = document.implementation.createHTMLDocument('');
|
|
1960
|
+
doc.open();
|
|
1961
|
+
doc.write(string);
|
|
1962
|
+
doc.close();
|
|
1963
|
+
return doc;
|
|
1964
|
+
};
|
|
1965
|
+
}
|
|
1966
|
+
}
|
|
1967
|
+
return Parser;
|
|
1968
|
+
}
|
|
1969
|
+
function shouldUseActiveX() {
|
|
1970
|
+
var useActiveX = false;
|
|
1971
|
+
try {
|
|
1972
|
+
document.implementation.createHTMLDocument('').open();
|
|
1973
|
+
} catch (e) {
|
|
1974
|
+
if (root.ActiveXObject) useActiveX = true;
|
|
1975
|
+
}
|
|
1976
|
+
return useActiveX;
|
|
1977
|
+
}
|
|
1978
|
+
var HTMLParser = canParseHTMLNatively() ? root.DOMParser : createHTMLParser();
|
|
1979
|
+
|
|
1980
|
+
function RootNode(input, options) {
|
|
1981
|
+
var root;
|
|
1982
|
+
if (typeof input === 'string') {
|
|
1983
|
+
var doc = htmlParser().parseFromString(
|
|
1984
|
+
// DOM parsers arrange elements in the <head> and <body>.
|
|
1985
|
+
// Wrapping in a custom element ensures elements are reliably arranged in
|
|
1986
|
+
// a single element.
|
|
1987
|
+
'<x-turndown id="turndown-root">' + input + '</x-turndown>', 'text/html');
|
|
1988
|
+
root = doc.getElementById('turndown-root');
|
|
1989
|
+
} else {
|
|
1990
|
+
root = input.cloneNode(true);
|
|
1991
|
+
}
|
|
1992
|
+
collapseWhitespace({
|
|
1993
|
+
element: root,
|
|
1994
|
+
isBlock: isBlock,
|
|
1995
|
+
isVoid: isVoid,
|
|
1996
|
+
isPre: options.preformattedCode ? isPreOrCode : null
|
|
1997
|
+
});
|
|
1998
|
+
return root;
|
|
1999
|
+
}
|
|
2000
|
+
var _htmlParser;
|
|
2001
|
+
function htmlParser() {
|
|
2002
|
+
_htmlParser = _htmlParser || new HTMLParser();
|
|
2003
|
+
return _htmlParser;
|
|
2004
|
+
}
|
|
2005
|
+
function isPreOrCode(node) {
|
|
2006
|
+
return node.nodeName === 'PRE' || node.nodeName === 'CODE';
|
|
2007
|
+
}
|
|
2008
|
+
|
|
2009
|
+
function Node(node, options) {
|
|
2010
|
+
node.isBlock = isBlock(node);
|
|
2011
|
+
node.isCode = node.nodeName === 'CODE' || node.parentNode.isCode;
|
|
2012
|
+
node.isBlank = isBlank(node);
|
|
2013
|
+
node.flankingWhitespace = flankingWhitespace(node, options);
|
|
2014
|
+
return node;
|
|
2015
|
+
}
|
|
2016
|
+
function isBlank(node) {
|
|
2017
|
+
return !isVoid(node) && !isMeaningfulWhenBlank(node) && /^\s*$/i.test(node.textContent) && !hasVoid(node) && !hasMeaningfulWhenBlank(node);
|
|
2018
|
+
}
|
|
2019
|
+
function flankingWhitespace(node, options) {
|
|
2020
|
+
if (node.isBlock || options.preformattedCode && node.isCode) {
|
|
2021
|
+
return {
|
|
2022
|
+
leading: '',
|
|
2023
|
+
trailing: ''
|
|
2024
|
+
};
|
|
2025
|
+
}
|
|
2026
|
+
var edges = edgeWhitespace(node.textContent);
|
|
2027
|
+
|
|
2028
|
+
// abandon leading ASCII WS if left-flanked by ASCII WS
|
|
2029
|
+
if (edges.leadingAscii && isFlankedByWhitespace('left', node, options)) {
|
|
2030
|
+
edges.leading = edges.leadingNonAscii;
|
|
2031
|
+
}
|
|
2032
|
+
|
|
2033
|
+
// abandon trailing ASCII WS if right-flanked by ASCII WS
|
|
2034
|
+
if (edges.trailingAscii && isFlankedByWhitespace('right', node, options)) {
|
|
2035
|
+
edges.trailing = edges.trailingNonAscii;
|
|
2036
|
+
}
|
|
2037
|
+
return {
|
|
2038
|
+
leading: edges.leading,
|
|
2039
|
+
trailing: edges.trailing
|
|
2040
|
+
};
|
|
2041
|
+
}
|
|
2042
|
+
function edgeWhitespace(string) {
|
|
2043
|
+
var m = string.match(/^(([ \t\r\n]*)(\s*))(?:(?=\S)[\s\S]*\S)?((\s*?)([ \t\r\n]*))$/);
|
|
2044
|
+
return {
|
|
2045
|
+
leading: m[1],
|
|
2046
|
+
// whole string for whitespace-only strings
|
|
2047
|
+
leadingAscii: m[2],
|
|
2048
|
+
leadingNonAscii: m[3],
|
|
2049
|
+
trailing: m[4],
|
|
2050
|
+
// empty for whitespace-only strings
|
|
2051
|
+
trailingNonAscii: m[5],
|
|
2052
|
+
trailingAscii: m[6]
|
|
2053
|
+
};
|
|
2054
|
+
}
|
|
2055
|
+
function isFlankedByWhitespace(side, node, options) {
|
|
2056
|
+
var sibling;
|
|
2057
|
+
var regExp;
|
|
2058
|
+
var isFlanked;
|
|
2059
|
+
if (side === 'left') {
|
|
2060
|
+
sibling = node.previousSibling;
|
|
2061
|
+
regExp = / $/;
|
|
2062
|
+
} else {
|
|
2063
|
+
sibling = node.nextSibling;
|
|
2064
|
+
regExp = /^ /;
|
|
2065
|
+
}
|
|
2066
|
+
if (sibling) {
|
|
2067
|
+
if (sibling.nodeType === 3) {
|
|
2068
|
+
isFlanked = regExp.test(sibling.nodeValue);
|
|
2069
|
+
} else if (options.preformattedCode && sibling.nodeName === 'CODE') {
|
|
2070
|
+
isFlanked = false;
|
|
2071
|
+
} else if (sibling.nodeType === 1 && !isBlock(sibling)) {
|
|
2072
|
+
isFlanked = regExp.test(sibling.textContent);
|
|
2073
|
+
}
|
|
2074
|
+
}
|
|
2075
|
+
return isFlanked;
|
|
2076
|
+
}
|
|
2077
|
+
|
|
2078
|
+
var reduce = Array.prototype.reduce;
|
|
2079
|
+
function TurndownService(options) {
|
|
2080
|
+
if (!(this instanceof TurndownService)) return new TurndownService(options);
|
|
2081
|
+
var defaults = {
|
|
2082
|
+
rules: rules,
|
|
2083
|
+
headingStyle: 'setext',
|
|
2084
|
+
hr: '* * *',
|
|
2085
|
+
bulletListMarker: '*',
|
|
2086
|
+
codeBlockStyle: 'indented',
|
|
2087
|
+
fence: '```',
|
|
2088
|
+
emDelimiter: '_',
|
|
2089
|
+
strongDelimiter: '**',
|
|
2090
|
+
linkStyle: 'inlined',
|
|
2091
|
+
linkReferenceStyle: 'full',
|
|
2092
|
+
br: ' ',
|
|
2093
|
+
preformattedCode: false,
|
|
2094
|
+
blankReplacement: function (content, node) {
|
|
2095
|
+
return node.isBlock ? '\n\n' : '';
|
|
2096
|
+
},
|
|
2097
|
+
keepReplacement: function (content, node) {
|
|
2098
|
+
return node.isBlock ? '\n\n' + node.outerHTML + '\n\n' : node.outerHTML;
|
|
2099
|
+
},
|
|
2100
|
+
defaultReplacement: function (content, node) {
|
|
2101
|
+
return node.isBlock ? '\n\n' + content + '\n\n' : content;
|
|
2102
|
+
}
|
|
2103
|
+
};
|
|
2104
|
+
this.options = extend({}, defaults, options);
|
|
2105
|
+
this.rules = new Rules(this.options);
|
|
2106
|
+
}
|
|
2107
|
+
TurndownService.prototype = {
|
|
2108
|
+
/**
|
|
2109
|
+
* The entry point for converting a string or DOM node to Markdown
|
|
2110
|
+
* @public
|
|
2111
|
+
* @param {String|HTMLElement} input The string or DOM node to convert
|
|
2112
|
+
* @returns A Markdown representation of the input
|
|
2113
|
+
* @type String
|
|
2114
|
+
*/
|
|
2115
|
+
|
|
2116
|
+
turndown: function (input) {
|
|
2117
|
+
if (!canConvert(input)) {
|
|
2118
|
+
throw new TypeError(input + ' is not a string, or an element/document/fragment node.');
|
|
2119
|
+
}
|
|
2120
|
+
if (input === '') return '';
|
|
2121
|
+
var output = process.call(this, new RootNode(input, this.options));
|
|
2122
|
+
return postProcess.call(this, output);
|
|
2123
|
+
},
|
|
2124
|
+
/**
|
|
2125
|
+
* Add one or more plugins
|
|
2126
|
+
* @public
|
|
2127
|
+
* @param {Function|Array} plugin The plugin or array of plugins to add
|
|
2128
|
+
* @returns The Turndown instance for chaining
|
|
2129
|
+
* @type Object
|
|
2130
|
+
*/
|
|
2131
|
+
|
|
2132
|
+
use: function (plugin) {
|
|
2133
|
+
if (Array.isArray(plugin)) {
|
|
2134
|
+
for (var i = 0; i < plugin.length; i++) this.use(plugin[i]);
|
|
2135
|
+
} else if (typeof plugin === 'function') {
|
|
2136
|
+
plugin(this);
|
|
2137
|
+
} else {
|
|
2138
|
+
throw new TypeError('plugin must be a Function or an Array of Functions');
|
|
2139
|
+
}
|
|
2140
|
+
return this;
|
|
2141
|
+
},
|
|
2142
|
+
/**
|
|
2143
|
+
* Adds a rule
|
|
2144
|
+
* @public
|
|
2145
|
+
* @param {String} key The unique key of the rule
|
|
2146
|
+
* @param {Object} rule The rule
|
|
2147
|
+
* @returns The Turndown instance for chaining
|
|
2148
|
+
* @type Object
|
|
2149
|
+
*/
|
|
2150
|
+
|
|
2151
|
+
addRule: function (key, rule) {
|
|
2152
|
+
this.rules.add(key, rule);
|
|
2153
|
+
return this;
|
|
2154
|
+
},
|
|
2155
|
+
/**
|
|
2156
|
+
* Keep a node (as HTML) that matches the filter
|
|
2157
|
+
* @public
|
|
2158
|
+
* @param {String|Array|Function} filter The unique key of the rule
|
|
2159
|
+
* @returns The Turndown instance for chaining
|
|
2160
|
+
* @type Object
|
|
2161
|
+
*/
|
|
2162
|
+
|
|
2163
|
+
keep: function (filter) {
|
|
2164
|
+
this.rules.keep(filter);
|
|
2165
|
+
return this;
|
|
2166
|
+
},
|
|
2167
|
+
/**
|
|
2168
|
+
* Remove a node that matches the filter
|
|
2169
|
+
* @public
|
|
2170
|
+
* @param {String|Array|Function} filter The unique key of the rule
|
|
2171
|
+
* @returns The Turndown instance for chaining
|
|
2172
|
+
* @type Object
|
|
2173
|
+
*/
|
|
2174
|
+
|
|
2175
|
+
remove: function (filter) {
|
|
2176
|
+
this.rules.remove(filter);
|
|
2177
|
+
return this;
|
|
2178
|
+
},
|
|
2179
|
+
/**
|
|
2180
|
+
* Escapes Markdown syntax
|
|
2181
|
+
* @public
|
|
2182
|
+
* @param {String} string The string to escape
|
|
2183
|
+
* @returns A string with Markdown syntax escaped
|
|
2184
|
+
* @type String
|
|
2185
|
+
*/
|
|
2186
|
+
|
|
2187
|
+
escape: function (string) {
|
|
2188
|
+
return escapeMarkdown(string);
|
|
2189
|
+
}
|
|
2190
|
+
};
|
|
2191
|
+
|
|
2192
|
+
/**
|
|
2193
|
+
* Reduces a DOM node down to its Markdown string equivalent
|
|
2194
|
+
* @private
|
|
2195
|
+
* @param {HTMLElement} parentNode The node to convert
|
|
2196
|
+
* @returns A Markdown representation of the node
|
|
2197
|
+
* @type String
|
|
2198
|
+
*/
|
|
2199
|
+
|
|
2200
|
+
function process(parentNode) {
|
|
2201
|
+
var self = this;
|
|
2202
|
+
return reduce.call(parentNode.childNodes, function (output, node) {
|
|
2203
|
+
node = new Node(node, self.options);
|
|
2204
|
+
var replacement = '';
|
|
2205
|
+
if (node.nodeType === 3) {
|
|
2206
|
+
replacement = node.isCode ? node.nodeValue : self.escape(node.nodeValue);
|
|
2207
|
+
} else if (node.nodeType === 1) {
|
|
2208
|
+
replacement = replacementForNode.call(self, node);
|
|
2209
|
+
}
|
|
2210
|
+
return join(output, replacement);
|
|
2211
|
+
}, '');
|
|
2212
|
+
}
|
|
2213
|
+
|
|
2214
|
+
/**
|
|
2215
|
+
* Appends strings as each rule requires and trims the output
|
|
2216
|
+
* @private
|
|
2217
|
+
* @param {String} output The conversion output
|
|
2218
|
+
* @returns A trimmed version of the ouput
|
|
2219
|
+
* @type String
|
|
2220
|
+
*/
|
|
2221
|
+
|
|
2222
|
+
function postProcess(output) {
|
|
2223
|
+
var self = this;
|
|
2224
|
+
this.rules.forEach(function (rule) {
|
|
2225
|
+
if (typeof rule.append === 'function') {
|
|
2226
|
+
output = join(output, rule.append(self.options));
|
|
2227
|
+
}
|
|
2228
|
+
});
|
|
2229
|
+
return output.replace(/^[\t\r\n]+/, '').replace(/[\t\r\n\s]+$/, '');
|
|
2230
|
+
}
|
|
2231
|
+
|
|
2232
|
+
/**
|
|
2233
|
+
* Converts an element node to its Markdown equivalent
|
|
2234
|
+
* @private
|
|
2235
|
+
* @param {HTMLElement} node The node to convert
|
|
2236
|
+
* @returns A Markdown representation of the node
|
|
2237
|
+
* @type String
|
|
2238
|
+
*/
|
|
2239
|
+
|
|
2240
|
+
function replacementForNode(node) {
|
|
2241
|
+
var rule = this.rules.forNode(node);
|
|
2242
|
+
var content = process.call(this, node);
|
|
2243
|
+
var whitespace = node.flankingWhitespace;
|
|
2244
|
+
if (whitespace.leading || whitespace.trailing) content = content.trim();
|
|
2245
|
+
return whitespace.leading + rule.replacement(content, node, this.options) + whitespace.trailing;
|
|
2246
|
+
}
|
|
2247
|
+
|
|
2248
|
+
/**
|
|
2249
|
+
* Joins replacement to the current output with appropriate number of new lines
|
|
2250
|
+
* @private
|
|
2251
|
+
* @param {String} output The current conversion output
|
|
2252
|
+
* @param {String} replacement The string to append to the output
|
|
2253
|
+
* @returns Joined output
|
|
2254
|
+
* @type String
|
|
2255
|
+
*/
|
|
2256
|
+
|
|
2257
|
+
function join(output, replacement) {
|
|
2258
|
+
var s1 = trimTrailingNewlines(output);
|
|
2259
|
+
var s2 = trimLeadingNewlines(replacement);
|
|
2260
|
+
var nls = Math.max(output.length - s1.length, replacement.length - s2.length);
|
|
2261
|
+
var separator = '\n\n'.substring(0, nls);
|
|
2262
|
+
return s1 + separator + s2;
|
|
2263
|
+
}
|
|
2264
|
+
|
|
2265
|
+
/**
|
|
2266
|
+
* Determines whether an input can be converted
|
|
2267
|
+
* @private
|
|
2268
|
+
* @param {String|HTMLElement} input Describe this parameter
|
|
2269
|
+
* @returns Describe what it returns
|
|
2270
|
+
* @type String|Object|Array|Boolean|Number
|
|
2271
|
+
*/
|
|
2272
|
+
|
|
2273
|
+
function canConvert(input) {
|
|
2274
|
+
return input != null && (typeof input === 'string' || input.nodeType && (input.nodeType === 1 || input.nodeType === 9 || input.nodeType === 11));
|
|
2275
|
+
}
|
|
2276
|
+
|
|
2277
|
+
return TurndownService;
|
|
2278
|
+
|
|
2279
|
+
})();
|
|
2280
|
+
// ══ vendored: turndown end ══
|
|
2281
|
+
|
|
2282
|
+
// ══ vendored: turndown-plugin-gfm start ══
|
|
2283
|
+
// turndown-plugin-gfm 1.0.2(MIT License)——逐字内联自官方发行版 dist/turndown-plugin-gfm.js。
|
|
2284
|
+
// 用途:turndown 的 GFM 扩展(表格 / 删除线 / 任务列表)——它们不在 turndown 核心内。
|
|
2285
|
+
// 官方仓库:https://github.com/mixmark-io/turndown-plugin-gfm
|
|
2286
|
+
/*
|
|
2287
|
+
MIT License
|
|
2288
|
+
|
|
2289
|
+
Copyright (c) 2017 Dom Christie
|
|
2290
|
+
|
|
2291
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
2292
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
2293
|
+
in the Software without restriction, including without limitation the rights
|
|
2294
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
2295
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
2296
|
+
furnished to do so, subject to the following conditions:
|
|
2297
|
+
|
|
2298
|
+
The above copyright notice and this permission notice shall be included in all
|
|
2299
|
+
copies or substantial portions of the Software.
|
|
2300
|
+
|
|
2301
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
2302
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
2303
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
2304
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
2305
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
2306
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
2307
|
+
SOFTWARE.
|
|
2308
|
+
*/
|
|
2309
|
+
var turndownPluginGfm = (function (exports) {
|
|
2310
|
+
'use strict';
|
|
2311
|
+
|
|
2312
|
+
var highlightRegExp = /highlight-(?:text|source)-([a-z0-9]+)/;
|
|
2313
|
+
|
|
2314
|
+
function highlightedCodeBlock (turndownService) {
|
|
2315
|
+
turndownService.addRule('highlightedCodeBlock', {
|
|
2316
|
+
filter: function (node) {
|
|
2317
|
+
var firstChild = node.firstChild;
|
|
2318
|
+
return (
|
|
2319
|
+
node.nodeName === 'DIV' &&
|
|
2320
|
+
highlightRegExp.test(node.className) &&
|
|
2321
|
+
firstChild &&
|
|
2322
|
+
firstChild.nodeName === 'PRE'
|
|
2323
|
+
)
|
|
2324
|
+
},
|
|
2325
|
+
replacement: function (content, node, options) {
|
|
2326
|
+
var className = node.className || '';
|
|
2327
|
+
var language = (className.match(highlightRegExp) || [null, ''])[1];
|
|
2328
|
+
|
|
2329
|
+
return (
|
|
2330
|
+
'\n\n' + options.fence + language + '\n' +
|
|
2331
|
+
node.firstChild.textContent +
|
|
2332
|
+
'\n' + options.fence + '\n\n'
|
|
2333
|
+
)
|
|
2334
|
+
}
|
|
2335
|
+
});
|
|
2336
|
+
}
|
|
2337
|
+
|
|
2338
|
+
function strikethrough (turndownService) {
|
|
2339
|
+
turndownService.addRule('strikethrough', {
|
|
2340
|
+
filter: ['del', 's', 'strike'],
|
|
2341
|
+
replacement: function (content) {
|
|
2342
|
+
return '~' + content + '~'
|
|
2343
|
+
}
|
|
2344
|
+
});
|
|
2345
|
+
}
|
|
2346
|
+
|
|
2347
|
+
var indexOf = Array.prototype.indexOf;
|
|
2348
|
+
var every = Array.prototype.every;
|
|
2349
|
+
var rules = {};
|
|
2350
|
+
|
|
2351
|
+
rules.tableCell = {
|
|
2352
|
+
filter: ['th', 'td'],
|
|
2353
|
+
replacement: function (content, node) {
|
|
2354
|
+
return cell(content, node)
|
|
2355
|
+
}
|
|
2356
|
+
};
|
|
2357
|
+
|
|
2358
|
+
rules.tableRow = {
|
|
2359
|
+
filter: 'tr',
|
|
2360
|
+
replacement: function (content, node) {
|
|
2361
|
+
var borderCells = '';
|
|
2362
|
+
var alignMap = { left: ':--', right: '--:', center: ':-:' };
|
|
2363
|
+
|
|
2364
|
+
if (isHeadingRow(node)) {
|
|
2365
|
+
for (var i = 0; i < node.childNodes.length; i++) {
|
|
2366
|
+
var border = '---';
|
|
2367
|
+
var align = (
|
|
2368
|
+
node.childNodes[i].getAttribute('align') || ''
|
|
2369
|
+
).toLowerCase();
|
|
2370
|
+
|
|
2371
|
+
if (align) border = alignMap[align] || border;
|
|
2372
|
+
|
|
2373
|
+
borderCells += cell(border, node.childNodes[i]);
|
|
2374
|
+
}
|
|
2375
|
+
}
|
|
2376
|
+
return '\n' + content + (borderCells ? '\n' + borderCells : '')
|
|
2377
|
+
}
|
|
2378
|
+
};
|
|
2379
|
+
|
|
2380
|
+
rules.table = {
|
|
2381
|
+
// Only convert tables with a heading row.
|
|
2382
|
+
// Tables with no heading row are kept using `keep` (see below).
|
|
2383
|
+
filter: function (node) {
|
|
2384
|
+
return node.nodeName === 'TABLE' && isHeadingRow(node.rows[0])
|
|
2385
|
+
},
|
|
2386
|
+
|
|
2387
|
+
replacement: function (content) {
|
|
2388
|
+
// Ensure there are no blank lines
|
|
2389
|
+
content = content.replace('\n\n', '\n');
|
|
2390
|
+
return '\n\n' + content + '\n\n'
|
|
2391
|
+
}
|
|
2392
|
+
};
|
|
2393
|
+
|
|
2394
|
+
rules.tableSection = {
|
|
2395
|
+
filter: ['thead', 'tbody', 'tfoot'],
|
|
2396
|
+
replacement: function (content) {
|
|
2397
|
+
return content
|
|
2398
|
+
}
|
|
2399
|
+
};
|
|
2400
|
+
|
|
2401
|
+
// A tr is a heading row if:
|
|
2402
|
+
// - the parent is a THEAD
|
|
2403
|
+
// - or if its the first child of the TABLE or the first TBODY (possibly
|
|
2404
|
+
// following a blank THEAD)
|
|
2405
|
+
// - and every cell is a TH
|
|
2406
|
+
function isHeadingRow (tr) {
|
|
2407
|
+
var parentNode = tr.parentNode;
|
|
2408
|
+
return (
|
|
2409
|
+
parentNode.nodeName === 'THEAD' ||
|
|
2410
|
+
(
|
|
2411
|
+
parentNode.firstChild === tr &&
|
|
2412
|
+
(parentNode.nodeName === 'TABLE' || isFirstTbody(parentNode)) &&
|
|
2413
|
+
every.call(tr.childNodes, function (n) { return n.nodeName === 'TH' })
|
|
2414
|
+
)
|
|
2415
|
+
)
|
|
2416
|
+
}
|
|
2417
|
+
|
|
2418
|
+
function isFirstTbody (element) {
|
|
2419
|
+
var previousSibling = element.previousSibling;
|
|
2420
|
+
return (
|
|
2421
|
+
element.nodeName === 'TBODY' && (
|
|
2422
|
+
!previousSibling ||
|
|
2423
|
+
(
|
|
2424
|
+
previousSibling.nodeName === 'THEAD' &&
|
|
2425
|
+
/^\s*$/i.test(previousSibling.textContent)
|
|
2426
|
+
)
|
|
2427
|
+
)
|
|
2428
|
+
)
|
|
2429
|
+
}
|
|
2430
|
+
|
|
2431
|
+
function cell (content, node) {
|
|
2432
|
+
var index = indexOf.call(node.parentNode.childNodes, node);
|
|
2433
|
+
var prefix = ' ';
|
|
2434
|
+
if (index === 0) prefix = '| ';
|
|
2435
|
+
return prefix + content + ' |'
|
|
2436
|
+
}
|
|
2437
|
+
|
|
2438
|
+
function tables (turndownService) {
|
|
2439
|
+
turndownService.keep(function (node) {
|
|
2440
|
+
return node.nodeName === 'TABLE' && !isHeadingRow(node.rows[0])
|
|
2441
|
+
});
|
|
2442
|
+
for (var key in rules) turndownService.addRule(key, rules[key]);
|
|
2443
|
+
}
|
|
2444
|
+
|
|
2445
|
+
function taskListItems (turndownService) {
|
|
2446
|
+
turndownService.addRule('taskListItems', {
|
|
2447
|
+
filter: function (node) {
|
|
2448
|
+
return node.type === 'checkbox' && node.parentNode.nodeName === 'LI'
|
|
2449
|
+
},
|
|
2450
|
+
replacement: function (content, node) {
|
|
2451
|
+
return (node.checked ? '[x]' : '[ ]') + ' '
|
|
2452
|
+
}
|
|
2453
|
+
});
|
|
2454
|
+
}
|
|
2455
|
+
|
|
2456
|
+
function gfm (turndownService) {
|
|
2457
|
+
turndownService.use([
|
|
2458
|
+
highlightedCodeBlock,
|
|
2459
|
+
strikethrough,
|
|
2460
|
+
tables,
|
|
2461
|
+
taskListItems
|
|
2462
|
+
]);
|
|
2463
|
+
}
|
|
2464
|
+
|
|
2465
|
+
exports.gfm = gfm;
|
|
2466
|
+
exports.highlightedCodeBlock = highlightedCodeBlock;
|
|
2467
|
+
exports.strikethrough = strikethrough;
|
|
2468
|
+
exports.tables = tables;
|
|
2469
|
+
exports.taskListItems = taskListItems;
|
|
2470
|
+
|
|
2471
|
+
return exports;
|
|
2472
|
+
|
|
2473
|
+
}({}));
|
|
2474
|
+
// ══ vendored: turndown-plugin-gfm end ══
|
|
2475
|
+
|
|
2476
|
+
exports.apply = apply
|
|
2477
|
+
|
|
2478
|
+
return module.exports
|
|
2479
|
+
},
|
|
2480
|
+
})
|