dsh-happy-reader 0.2.7 → 0.3.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/lib/client.js CHANGED
@@ -1,27 +1,18 @@
1
- // dsh-happy-reader(Happy Reader)—— 浏览器半边
1
+ // dsh-happy-reader(Happy Reader)—— 浏览器半边(官方客户端插件包格式:
2
+ // __ModuleLoader__.load({ id: 包名, factory }),factory 导出 { inject: ['slots'], apply })。
2
3
  //
3
- // 九项功能:
4
- // ① 隐藏输入区(composer)
5
- // ② 隐藏上边栏(会话标题栏;最上方的系统窗口标题条是系统窗口控制区,未触碰)
6
- // ③ 字号 10–40px(10–22 与官方设置直连,超出部分用缩放层补齐;代码块内容随之同步缩放)
7
- // ④ 内容宽度(官方范围内与官方手柄同键同步;超出官方钳制上限时扩展,拖手柄即交还)
8
- // ⑤ 字体选择(正文字体·中文/西文分设 + 代码字体,只列出本机已装字体)
9
- // ⑥ 全屏(网页全屏 API;Esc 或再点开关退出,状态与真实全屏双向同步)
10
- // ⑦ 行距 / 字距(面板最下方;1.0× / 0px 即官方原样)
11
- // ⑧ 面板可拖动 / 可拉拽高度(双击标题栏复位为自动布局;位置与高度都会记住)
12
- // ⑨ 复制为 Markdown(选中消息内容复制时,公式还原为 LaTeX 源码、代码转围栏等)
4
+ // 功能:隐藏输入区/上边栏、字号 10–40(10–22 直连官方,超出缩放补齐)、内容宽度、
5
+ // 字体(中英分设 + 官方正文/代码/终端角色镜像)、全屏、行距/字距、
6
+ // 面板拖动/拉高、复制为 Markdown。
13
7
  //
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,逐字内联于文件尾)
8
+ // 关键约束:
9
+ // · 隐藏输入区用 height:0(官方滚动锚定依赖几何),不用 display:none
10
+ // · <style>.disabled 必须挂载后再设(未连接时设置会被 CSSOM 静默丢弃)
11
+ // · 文本 UI 只走官方 shell.overlay / settings.general.item 槽位、locale 与主题 token
12
+ // · 官方字号/字体走 theme.setFontSize / setFontFamily 角色接口;中英分字用
13
+ // @font-face(local()+unicode-range) 补官方单一字族表达不了的部分
14
+ // · 选区复制:KaTeX annotation 还原公式,turndown + gfm 生成 Markdown(文件尾 vendored)
22
15
  //
23
- // 本文件采用官方客户端插件包格式:外壳加载时全局 __ModuleLoader__ 已就绪,
24
- // 登记 { id: 包名, factory };factory 在插件挂载时执行,导出的 apply 即插件本体。
25
16
  window.__ModuleLoader__.load({
26
17
  id: 'dsh-happy-reader',
27
18
  factory: (require) => {
@@ -29,7 +20,9 @@ window.__ModuleLoader__.load({
29
20
  var exports = module.exports
30
21
 
31
22
  // ── 常量 ────────────────────────────────────────────────────────────
32
- const VERSION = '0.2.7'
23
+ const VERSION = '0.3.1'
24
+ const INSTANCE_TOKEN = {}
25
+ const GROUPS_VERSION = 3
33
26
  const STATE_KEY = 'dsh.reader-boost.v1'
34
27
  const FAB_ID = 'dsrb-fab'
35
28
  const PANEL_ID = 'dsrb-panel'
@@ -41,7 +34,7 @@ window.__ModuleLoader__.load({
41
34
  const FS_STYLE_ID = 'dsrb-fullscreen'
42
35
  const FAB_SIZE = 40
43
36
  const PANEL_WIDTH = 280
44
- const PANEL_MAX_H = 480 // 面板高度锁:新增选项不再增高面板,超出部分由内部滚动查看
37
+ const PANEL_MAX_H = 560 // 面板高度锁:新增选项不再增高面板,超出部分由内部滚动查看
45
38
  const PANEL_MIN_H = 180 // 面板拉拽高度的下限
46
39
  const BORDER_Y = 2 // 面板上下边框各 1px(外框高 = 内容高 + 2,定位预算统一用它)
47
40
 
@@ -54,6 +47,12 @@ window.__ModuleLoader__.load({
54
47
  const FONT_MIN = 10
55
48
  const FONT_MAX = 40
56
49
  const FONT_OFFICIAL_MAX = 22
50
+ // 官方代码字号范围(0.2.2 起 theme.setFontSize('code') 只接受 10–16 的整数)
51
+ const CODE_FONT_MIN = 10
52
+ const CODE_FONT_MAX = 16
53
+ // 官方终端字号范围(theme.setFontSize('terminal') 10–20)
54
+ const TERMINAL_FONT_MIN = 10
55
+ const TERMINAL_FONT_MAX = 20
57
56
 
58
57
  // 行距倍率与字距范围(0.1 步进;边界值 = 完全还原官方排版)
59
58
  const LINEH_MIN = 1
@@ -66,105 +65,145 @@ window.__ModuleLoader__.load({
66
65
 
67
66
  // ── 样式 ────────────────────────────────────────────────────────────
68
67
  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;
68
+ .dsrb-slot-host{pointer-events:none}
69
+ /* 表单控件默认不继承字体:统一继承宿主/面板字体 */
70
+ .dsrb-panel button,.dsrb-panel input,.dsrb-panel select,.dsrb-settings select{font-family:inherit}
71
+ /* 旧外壳无官方 overlay 层时的回退宿主:自成视口层,保证不被页面内容盖住 */
72
+ .dsrb-slot-host.dsrb-standalone{position:fixed;inset:0;z-index:9999;pointer-events:none}
73
+ .dsrb-fab{position:fixed;box-sizing:border-box;width:40px;height:40px;border-radius:12px;
74
+ border:1px solid var(--dsw-alias-border-l3,rgba(128,128,128,.35));
75
+ background:var(--dsw-alias-button-floating-fill,rgba(28,28,30,.85));
76
+ color:var(--dsw-alias-label-primary,#f5f5f5);
71
77
  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)}
78
+ justify-content:center;cursor:grab;user-select:none;-webkit-user-select:none;pointer-events:auto;
79
+ box-shadow:var(--dsw-elevation-panel,0 4px 14px rgba(0,0,0,.28));backdrop-filter:blur(6px)}
80
+ .dsrb-fab:hover{background:var(--dsw-alias-button-floating-hover,rgba(40,40,42,.92))}
74
81
  .dsrb-fab:active{cursor:grabbing}
75
- .dsrb-panel{position:fixed;z-index:9999;width:${PANEL_WIDTH}px;padding:0;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;
82
+ .dsrb-panel{position:fixed;width:${PANEL_WIDTH}px;padding:0;border-radius:var(--dsw-radius-lg,16px);
83
+ max-height:calc(100vh - 18px);display:flex;flex-direction:column;overflow:hidden;pointer-events:auto;
84
+ border:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.3));
85
+ background:var(--dsw-specific-menu,var(--dsw-alias-bg-layer-2,rgba(28,28,30,.96)));
86
+ color:var(--dsw-alias-label-primary,#ececec);
78
87
  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)}
88
+ box-shadow:var(--dsw-elevation-prominent,var(--dsw-elevation-panel,0 8px 28px rgba(0,0,0,.38)));backdrop-filter:blur(8px)}
80
89
  /* 横向内边距放在滚动区:面板零内边距,滚动条才能贴住面板右缘 */
81
90
  .dsrb-body{flex:1 1 auto;min-height:0;overflow-y:auto;overscroll-behavior:contain;padding:12px 14px}
82
91
  .dsrb-body::-webkit-scrollbar{width:8px}
83
- .dsrb-body::-webkit-scrollbar-thumb{background:rgba(128,128,128,0);border-radius:4px;transition:background .15s}
84
- .dsrb-body:hover::-webkit-scrollbar-thumb{background:rgba(128,128,128,.45)}
85
- .dsrb-body:hover::-webkit-scrollbar-thumb:hover{background:rgba(128,128,128,.65)}
92
+ .dsrb-body::-webkit-scrollbar-thumb{background:transparent;border-radius:4px;transition:background .15s}
93
+ .dsrb-body:hover::-webkit-scrollbar-thumb{background:var(--dsw-alias-scrollbar-bg-l2,rgba(128,128,128,.45))}
94
+ .dsrb-body:hover::-webkit-scrollbar-thumb:hover{background:var(--dsw-alias-scrollbar-hover-l2,rgba(128,128,128,.65))}
86
95
  .dsrb-body::-webkit-scrollbar-track{background:transparent}
96
+ @media (hover:none){.dsrb-body::-webkit-scrollbar-thumb{background:var(--dsw-alias-scrollbar-bg-l2,rgba(128,128,128,.45))}}
87
97
  .dsrb-head{display:flex;align-items:center;justify-content:space-between;margin:0 0 4px;cursor:grab}
88
98
  .dsrb-head:active{cursor:grabbing}
89
- .dsrb-resize{flex:none;height:10px;cursor:ns-resize;border-radius:0 0 12px 12px}
90
- .dsrb-resize-top{flex:none;height:10px;cursor:ns-resize;border-radius:12px 12px 0 0}
91
- .dsrb-resize:hover,.dsrb-resize-top:hover{background:rgba(255,255,255,.07)}
92
- .dsrb-head h4{margin:0;font-size:13px;font-weight:600}
93
- .dsrb-headHint{font-size:11.5px;color:#8d8d8d;margin-left:auto;margin-right:8px;white-space:nowrap}
94
- .dsrb-x{width:22px;height:22px;padding:0;border-radius:6px;border:1px solid rgba(128,128,128,.3);
95
- background:rgba(255,255,255,.06);color:#ddd;font-size:13px;line-height:1;cursor:pointer}
96
- .dsrb-x:hover{background:rgba(255,255,255,.16)}
99
+ .dsrb-resize{flex:none;height:10px;cursor:ns-resize;border-radius:0 0 var(--dsw-radius-lg,16px) var(--dsw-radius-lg,16px)}
100
+ .dsrb-resize-top{flex:none;height:10px;cursor:ns-resize;border-radius:var(--dsw-radius-lg,16px) var(--dsw-radius-lg,16px) 0 0}
101
+ .dsrb-resize:hover,.dsrb-resize-top:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(255,255,255,.07))}
102
+ .dsrb-head h4{margin:0;font-size:14px;font-weight:600}
103
+ .dsrb-headHint{font-size:11.5px;color:var(--dsw-alias-label-tertiary,#8d8d8d);margin-left:auto;margin-right:8px;white-space:nowrap}
104
+ .dsrb-x{width:22px;height:22px;padding:0;border-radius:6px;
105
+ border:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.3));
106
+ background:transparent;color:var(--dsw-alias-label-secondary,#ddd);font-size:13px;line-height:1;cursor:pointer}
107
+ .dsrb-x:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(255,255,255,.16))}
97
108
  .dsrb-row{display:flex;align-items:flex-start;gap:10px;padding:8px 0;
98
- border-top:1px solid rgba(128,128,128,.14)}
99
- .dsrb-head+.dsrb-row{border-top:0}
109
+ border-top:1px solid var(--dsw-alias-border-l1,rgba(128,128,128,.14))}
100
110
  .dsrb-row-click{cursor:pointer}
101
- .dsrb-row-click:hover{background:rgba(255,255,255,.045)}
111
+ .dsrb-row-click:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(255,255,255,.045))}
102
112
  .dsrb-rowMain{flex:1;min-width:0}
103
113
  .dsrb-rowTitle{font-weight:600}
104
- .dsrb-rowTitle b{color:#fff;font-weight:600}
105
- .dsrb-rowSub{color:#9a9a9a;font-size:12px;margin-top:2px}
106
- .dsrb-switch{position:relative;flex:none;width:34px;height:20px;margin-top:1px;cursor:pointer}
114
+ .dsrb-rowTitle b{color:var(--dsw-alias-label-primary,#fff);font-weight:600}
115
+ .dsrb-rowSub{color:var(--dsw-alias-label-secondary,#9a9a9a);font-size:12px;margin-top:2px}
116
+ /* 开关:对齐官方 role=switch 视觉(36×20、2px 内边距、16px 滑块、16px 行程) */
117
+ .dsrb-switch{position:relative;flex:none;width:36px;height:20px;padding:2px;box-sizing:border-box;
118
+ margin-top:1px;cursor:pointer;border-radius:999px;background:var(--dsw-alias-border-l3,rgba(120,120,120,.35));
119
+ transition:background .12s}
107
120
  .dsrb-switch input{position:absolute;inset:0;opacity:0;margin:0;pointer-events:none}
108
- .dsrb-switch span{position:absolute;inset:0;border-radius:10px;background:rgba(120,120,120,.35);
109
- transition:background .15s;pointer-events:none}
121
+ .dsrb-switch span{position:absolute;inset:0;border-radius:999px;pointer-events:none}
110
122
  .dsrb-switch span:after{content:"";position:absolute;top:2px;left:2px;width:16px;height:16px;
111
- border-radius:50%;background:#fff;transition:transform .15s}
112
- .dsrb-switch input:checked+span{background:#2f7af0}
113
- .dsrb-switch input:checked+span:after{transform:translateX(14px)}
123
+ border-radius:50%;background:var(--dsw-alias-switch-thumb,#fff);transition:transform .12s}
124
+ .dsrb-switch input:checked+span:after{transform:translateX(16px);background:var(--dsw-alias-label-primary-foreground,#fff)}
125
+ .dsrb-switch:has(input:checked){background:var(--dsw-alias-brand-primary,#2f7af0)}
126
+ .dsrb-switch:has(input:focus-visible){outline:var(--dsw-focus-ring-width,2px) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary,#2f7af0));outline-offset:2px}
114
127
  .dsrb-stepper{flex:none;display:flex;gap:6px;align-items:center}
115
- .dsrb-stepper button{width:28px;height:28px;border-radius:8px;border:1px solid rgba(128,128,128,.3);
116
- background:rgba(255,255,255,.06);color:#eee;font-size:14px;line-height:1;cursor:pointer}
117
- .dsrb-stepper button:hover{background:rgba(255,255,255,.14)}
128
+ .dsrb-stepper button{width:30px;height:30px;border-radius:8px;
129
+ border:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.3));background:transparent;
130
+ color:var(--dsw-alias-label-primary,#eee);font-size:14px;line-height:1;cursor:pointer}
131
+ .dsrb-stepper button:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(255,255,255,.14))}
118
132
  .dsrb-stepper button:disabled{opacity:.45;cursor:default}
119
133
  .dsrb-widthRow{display:flex;align-items:center;gap:8px;margin-top:7px}
120
- .dsrb-widthRow input[type=range]{flex:1;min-width:0}
134
+ .dsrb-widthRow input[type=range]{flex:1;min-width:0;accent-color:var(--dsw-alias-brand-primary,#2f7af0)}
121
135
  .dsrb-widthRow button{flex:none;height:24px;padding:0 10px;border-radius:8px;
122
- border:1px solid rgba(128,128,128,.3);background:rgba(255,255,255,.06);color:#eee;
123
- font-size:12px;cursor:pointer}
124
- .dsrb-widthRow button:hover{background:rgba(255,255,255,.14)}
136
+ border:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.3));background:transparent;
137
+ color:var(--dsw-alias-label-primary,#eee);font-size:12px;cursor:pointer}
138
+ .dsrb-widthRow button:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(255,255,255,.14))}
125
139
  .dsrb-widthRow button:disabled{opacity:.45;cursor:default}
126
- .dsrb-select{width:100%;margin-top:7px;height:26px;padding:0 6px;border-radius:8px;
127
- border:1px solid rgba(128,128,128,.3);background:rgba(255,255,255,.06);color:#eee;
140
+ .dsrb-select{box-sizing:border-box;width:100%;margin-top:7px;height:30px;padding:0 8px;border-radius:8px;
141
+ border:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.3));
142
+ background:var(--dsw-alias-bg-layer-2,rgba(255,255,255,.06));color:var(--dsw-alias-label-primary,#eee);
128
143
  font-size:12px;cursor:pointer}
129
- .dsrb-select:focus{outline:none;border-color:#2f7af0}
130
- .dsrb-select option{background:#1c1c1e;color:#eee}
144
+ .dsrb-select:focus{outline:2px solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary,#2f7af0));outline-offset:-2px}
145
+ .dsrb-select option{background:var(--dsw-alias-bg-layer-3,#1c1c1e);color:var(--dsw-alias-label-primary,#eee)}
131
146
  .dsrb-pair{display:flex;gap:8px;margin-top:7px}
132
147
  .dsrb-pairCol{flex:1;min-width:0}
133
- .dsrb-mini{font-size:11px;color:#9a9a9a;margin-bottom:3px}
148
+ .dsrb-mini{font-size:11px;color:var(--dsw-alias-label-secondary,#9a9a9a);margin-bottom:3px}
134
149
  .dsrb-pairCol .dsrb-select{margin-top:0}
135
- /* 代码块随字号缩放(v0.2.7):官方代码块 token 固定 11px 不随字号,按官方标题同款增量写法
136
- 补齐;14px 时计算值与官方原值一致(零覆盖痕迹)。:root body 提权以压过官方主题对同名
137
- token 的任何 body/root 重声明;字族仍引用官方变量,字体选择链路不变 */
138
- :root body{--dsw-font-markdown-code-block:calc(11px + var(--dsh-content-font-delta))/calc(19px + var(--dsh-content-font-delta)) var(--ds-font-family-code);--dsw-font-markdown-code-block-small:calc(11px + var(--dsh-content-font-delta))/calc(16px + var(--dsh-content-font-delta)) var(--ds-font-family-code)}
150
+ /* 字体设置折叠组(正文字体 / 代码字体 / 终端字体):标题行整条可点,chevron 指示展开态 */
151
+ .dsrb-group{display:flex;flex-direction:column;margin:6px 0;border:1px solid var(--dsw-alias-settings-card-stroke,var(--dsw-alias-border-l2,rgba(128,128,128,.22)));
152
+ border-radius:var(--dsw-radius-md,12px);overflow:hidden;
153
+ background:var(--dsw-alias-settings-card-fill,var(--dsw-alias-bg-layer-1,#232324));
154
+ background:color-mix(in srgb,var(--dsw-alias-settings-card-fill,var(--dsw-alias-bg-layer-1,#232324)) 78%,transparent)}
155
+ .dsrb-groupHead{display:flex;align-items:center;gap:8px;box-sizing:border-box;width:100%;padding:10px 12px;border:0;background:transparent;
156
+ color:inherit;font:inherit;text-align:left;cursor:pointer}
157
+ .dsrb-groupHead:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(255,255,255,.045))}
158
+ .dsrb-groupHead:focus-visible,.dsrb-fab:focus-visible,.dsrb-x:focus-visible,.dsrb-stepper button:focus-visible,.dsrb-widthRow button:focus-visible{
159
+ outline:var(--dsw-focus-ring-width,2px) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary,#2f7af0));outline-offset:-2px}
160
+ .dsrb-groupBody .dsrb-row{padding:8px 12px}
161
+ .dsrb-groupTitle{flex:1;min-width:0;font-size:14px;font-weight:600;color:var(--dsw-alias-label-primary)}
162
+ .dsrb-groupSub{flex:none;max-width:55%;overflow:hidden;text-overflow:ellipsis;color:var(--dsw-alias-label-secondary,#9a9a9a);font-size:12px;white-space:nowrap}
163
+ .dsrb-chevron{flex:none;width:20px;height:20px;position:relative;color:var(--dsw-alias-label-tertiary,#8d8d8d)}
164
+ .dsrb-chevron:before{content:'';position:absolute;left:5px;top:5px;width:6px;height:6px;
165
+ border-right:1.5px solid currentColor;border-bottom:1.5px solid currentColor;
166
+ transform:rotate(45deg);transition:transform var(--ds-transition-duration-fast,.1s) var(--ds-ease-in-out,ease)}
167
+ .dsrb-groupHead[aria-expanded="true"] .dsrb-chevron:before{transform:rotate(-135deg);top:8px}
168
+ .dsrb-groupBody{display:flex;flex-direction:column}
169
+ .dsrb-input{width:100%;margin-top:7px;height:30px;padding:0 8px;box-sizing:border-box;border-radius:8px;
170
+ border:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.3));
171
+ background:var(--dsw-alias-bg-layer-2,rgba(255,255,255,.06));color:var(--dsw-alias-label-primary,#eee);
172
+ font-size:12px}
173
+ .dsrb-input:focus{outline:2px solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary,#2f7af0));outline-offset:-2px}
174
+ /* 官方「通用设置」内的字体行(settings.general.item):沿用官方行距/字号/分隔线规格 */
175
+ .dsrb-settings{width:100%}
176
+ .dsrb-set-row{padding:16px 0;border-bottom:.5px solid var(--dsw-alias-border-l2);display:flex;flex-direction:column;gap:8px}
177
+ .dsrb-set-title{color:var(--dsw-alias-label-primary);font-size:14px;font-weight:400;line-height:22px}
178
+ .dsrb-set-desc{color:var(--dsw-alias-label-tertiary);font-size:12px;font-weight:400;line-height:18px}
179
+ .dsrb-set-select{box-sizing:border-box;width:100%;height:36px;padding:0 10px;border-radius:var(--dsw-radius-md,12px);
180
+ border:1px solid transparent;background:var(--dsw-alias-bg-layer-1,var(--dsw-alias-bg-module-platform,rgba(128,128,128,.12)));
181
+ color:var(--dsw-alias-label-primary);font-size:14px;line-height:1.4;font-family:inherit;cursor:pointer}
182
+ .dsrb-set-select:focus{outline:2px solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary,#2f7af0));outline-offset:-2px}
183
+ .dsrb-set-select option{background:var(--dsw-alias-bg-layer-3,#1c1c1e);color:var(--dsw-alias-label-primary,#eee)}
139
184
  `
140
185
  // 隐藏输入区(关键:height:0 而非 display:none —— 官方滚动锚定依赖该元素的几何信息)
141
186
  const HIDE_CSS = '[data-composer-seat]{height:0!important;min-height:0!important;overflow:hidden!important}'
142
- // 隐藏上边栏(会话标题栏)—— 正向判据:官方 "conversation.header" 槽位宿主带
143
- // data-conversation-header-leading,全应用仅此一处;其余 header[data-window-drag]
144
- // 各带关键控件(插件页标题含"添加插件"按钮、嵌入页头含唯一返回按钮),不得误伤
145
- // (v0.2.6 修:旧写法"匹配全部再排除对话框"曾把插件页标题整条隐藏,安装入口消失)。
146
- // 对话框排除条款保留:弹出层内永不隐藏(防锁死返回,v0.10.4)。
187
+ // 隐藏上边栏:正向判据(官方 conversation.header 槽位宿主带 data-conversation-header-leading,
188
+ // 全应用仅此一处);排除对话框内,避免误伤插件页/嵌入页的头部。
147
189
  const HIDE_TOP_CSS = 'header[data-window-drag]:has(> [data-conversation-header-leading]):not([role="dialog"] *){display:none!important}'
148
190
  // 字号缩放层(!important 样式表仅在需要缩放时启用;官方内联值原样保留在其下)
149
191
  const FONT_CSS = `body{--dsh-content-font-size:calc(var(--dsrb-font-base,14px)*var(--dsrb-font-scale,1))!important}`
150
- // 全屏补偿层:不归零官方定位变量 —— --dsh-windows-titlebar-height(=40px)是
151
- // "开合/新对话"悬浮按钮与"应用/编辑"菜单的定位基准,归零会裁掉它们的上半截。
152
- // 改为逐项覆盖:frame 顶部预留条清零、侧栏列自留该变量、收态归零内容圆角、
153
- // 全屏隐藏菜单;规则由 html[data-dsrb-fullscreen] 门控,退出全屏即完全还原。
154
- // 注:.BynINW_* 为应用构建期类名(随应用更新可能变;失效即表现为全屏残留,
155
- // 按新构建重新对齐即可——无更稳定锚点可用,属原则②下已接受的取舍)。
192
+ // 全屏补偿:官方 40px 窗口预留条在网页全屏下仍占位,需逐项清零;不能归零
193
+ // --dsh-windows-titlebar-height(悬浮按钮/菜单的定位基准)。.mP3x-q_* 是构建期类名,
194
+ // 无稳定锚点,升级后按新构建重新对齐;规则由 html[data-dsrb-fullscreen] 门控。
156
195
  const FS_CSS = [
157
196
  // 镜像官方 html[data-windows-titlebar][data-fullscreen]:overlay 顶距 20px、chrome 顶距 0
158
197
  'html[data-windows-titlebar][data-dsrb-fullscreen]{--dsh-frame-overlay-top:20px;--dsh-frame-chrome-top:0px}',
159
198
  // frame 的 40px 预留条清零 → 两侧列内容顶到屏幕最上缘
160
- 'html[data-dsrb-fullscreen] .BynINW_frame{padding-top:0!important}',
161
- 'html[data-dsrb-fullscreen] .BynINW_frame::before{height:0!important}',
199
+ 'html[data-dsrb-fullscreen] .mP3x-q_frame{padding-top:0!important}',
200
+ 'html[data-dsrb-fullscreen] .mP3x-q_frame::before{height:0!important}',
162
201
  // 收起态:内容列左上圆角归零(收起时圆角在 (0,0) 露出 frame 底色成为缺口)
163
- 'html[data-dsrb-fullscreen]:has([data-sidebar-collapsed=true]) .BynINW_centerCol{border-radius:0!important}',
202
+ 'html[data-dsrb-fullscreen]:has([data-sidebar-collapsed=true]) .mP3x-q_centerCol{border-radius:0!important}',
164
203
  // 展开态:侧栏列自留 40px 顶部,悬浮按钮下方内容位置与窗口态保持一致
165
- 'html[data-dsrb-fullscreen] .BynINW_sidebarCol{padding-top:var(--dsh-windows-titlebar-height)}',
204
+ 'html[data-dsrb-fullscreen] .mP3x-q_sidebarCol{padding-top:var(--dsh-windows-titlebar-height)}',
166
205
  // 侧栏拖拽手柄随之顶到 0(其 top 同样以 var 为基准)
167
- 'html[data-dsrb-fullscreen] .BynINW_handle{top:0!important}',
206
+ 'html[data-dsrb-fullscreen] .mP3x-q_handle{top:0!important}',
168
207
  // 全屏隐藏 preload"应用/编辑"菜单(退出全屏即恢复;无键盘通道,不影响 Esc 退出)
169
208
  'html[data-dsrb-fullscreen] [data-windows-menu]{display:none!important}'
170
209
  ].join('')
@@ -177,7 +216,8 @@ window.__ModuleLoader__.load({
177
216
  function readState() {
178
217
  try {
179
218
  const raw = localStorage.getItem(STATE_KEY)
180
- return raw ? JSON.parse(raw) : {}
219
+ const value = raw ? JSON.parse(raw) : null
220
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {}
181
221
  } catch {
182
222
  return {}
183
223
  }
@@ -238,15 +278,16 @@ window.__ModuleLoader__.load({
238
278
  }
239
279
  return out
240
280
  }
281
+ // 代码字体候选(仅列本机已装;选中后经官方 setFontFamily('code') 写入,不再自建 @font-face)
241
282
  const CODE_FONTS = [
242
- { id: 'consolas', label: 'Consolas', css: 'Consolas', name: 'Consolas' },
243
- { id: 'cascadia', label: 'Cascadia Code', css: '"Cascadia Code"', name: 'Cascadia Code' },
244
- { id: 'cascadiamono', label: 'Cascadia Mono', css: '"Cascadia Mono"', name: 'Cascadia Mono' },
245
- { id: 'courier', label: 'Courier New', css: '"Courier New"', name: 'Courier New' },
246
- { id: 'nsimsun', label: '新宋体', css: '"NSimSun"', name: 'NSimSun' },
247
- { id: 'firacode', label: 'Fira Code', css: '"Fira Code"', name: 'Fira Code' },
248
- { id: 'jetbrains', label: 'JetBrains Mono', css: '"JetBrains Mono"', name: 'JetBrains Mono' },
249
- { id: 'sourcecode', label: 'Source Code Pro', css: '"Source Code Pro"', name: 'Source Code Pro' },
283
+ { id: 'consolas', label: 'Consolas', name: 'Consolas' },
284
+ { id: 'cascadia', label: 'Cascadia Code', name: 'Cascadia Code' },
285
+ { id: 'cascadiamono', label: 'Cascadia Mono', name: 'Cascadia Mono' },
286
+ { id: 'courier', label: 'Courier New', name: 'Courier New' },
287
+ { id: 'nsimsun', label: '新宋体', name: 'NSimSun' },
288
+ { id: 'firacode', label: 'Fira Code', name: 'Fira Code' },
289
+ { id: 'jetbrains', label: 'JetBrains Mono', name: 'JetBrains Mono' },
290
+ { id: 'sourcecode', label: 'Source Code Pro', name: 'Source Code Pro' },
250
291
  ]
251
292
  let fontDetectCtx = null
252
293
  // 字体可用性检测:canvas 字宽比对(与三套通用字族任一不同 = 本机已装)。
@@ -271,6 +312,13 @@ window.__ModuleLoader__.load({
271
312
  return true
272
313
  }
273
314
  }
315
+ /** 字体候选与官方值首个名字是否同一字体(英文名 / 本地化名都认)。 */
316
+ function fontMatches(f, first) {
317
+ const want = String(first || '').toLowerCase()
318
+ if (!want) return false
319
+ if (f.name.toLowerCase() === want) return true
320
+ return (f.names || []).some((n) => String(n).toLowerCase() === want)
321
+ }
274
322
  function fontChoice(list, id) {
275
323
  if (!id) return null
276
324
  for (const f of list) if (f.id === id) return f
@@ -279,6 +327,7 @@ window.__ModuleLoader__.load({
279
327
  // 官方把整套排印 token(--dsw-font-* / --ds-font-*)定义在 :root 与 body 上。
280
328
  // 成功解析一次后缓存;官方主题尚未就绪时返回 null(调用方靠定时器稍后重试)。
281
329
  let themeFontDecls = null
330
+ let lastThemePreference
282
331
  function readThemeFontDecls() {
283
332
  if (themeFontDecls) return themeFontDecls
284
333
  const decls = new Map()
@@ -301,51 +350,35 @@ window.__ModuleLoader__.load({
301
350
  if (!decls) return null
302
351
  const cjk = fontChoice(TEXT_FONTS_CJK, state.textFontCjk)
303
352
  const lat = fontChoice(TEXT_FONTS_LATIN, state.textFontLatin)
304
- const code = fontChoice(CODE_FONTS, state.codeFont)
305
353
  const lineH = typeof state.lineHeight === 'number' ? state.lineHeight : null
306
354
  const letterSp = typeof state.letterSpacing === 'number' ? state.letterSpacing : null
307
- if (!cjk && !lat && !code && lineH === null && letterSp === null) return null
355
+ if (!cjk && !lat && lineH === null && letterSp === null) return null
308
356
  const offText = decls.get('--dsw-font-family')
309
- const offCode = decls.get('--ds-font-family-code')
310
357
  // 虚拟字族按 西文→中文 排序:引号/破折号等共享区间由西文面优先接住
311
358
  const faces = []
312
359
  const famNames = []
313
360
  if (lat) { faces.push(faceCss('dsrb-text-latin', lat, LATIN_RANGE)); famNames.push('"dsrb-text-latin"') }
314
361
  if (cjk) { faces.push(faceCss('dsrb-text-cjk', cjk, CJK_RANGE)); famNames.push('"dsrb-text-cjk"') }
315
362
  const fam = famNames.length ? 'var(--dsrb-family)' : offText
316
- const cod = code ? 'var(--dsrb-code)' : offCode
317
363
  const parts = []
318
364
  // 虚拟字族在前(按区间分流);官方原栈殿后兜底:区间外字符与缺字逐字回退
319
365
  if (famNames.length) parts.push('--dsrb-family:' + famNames.join(', ') + ', ' + offText)
320
- if (code) parts.push('--dsrb-code:' + code.css + ', ' + offCode)
321
366
  if (famNames.length) parts.push('--dsw-font-family:' + fam)
322
- if (code) parts.push('--ds-font-family-code:' + cod)
323
367
  if (famNames.length) parts.push('font-family:var(--dsrb-family)') // 无自身字族的后代(如用户气泡正文)由继承拿到
324
368
  for (const [name, value] of decls) {
325
369
  if (name === '--dsw-font-family' || name === '--ds-font-family-code') continue
326
370
  let next = value
327
371
  if (famNames.length && next.indexOf('var(--dsw-font-family)') !== -1) next = next.split('var(--dsw-font-family)').join(fam)
328
- if (code && next.indexOf('var(--ds-font-family-code)') !== -1) next = next.split('var(--ds-font-family-code)').join(cod)
329
- // 代码块类 token 并入字号增量(v0.2.7;官方原版固定 11px 不随字号,基础层已有同式
330
- // 兜底覆盖,这里只在因字族/行距本就需重发时带上,维持"非必要不重发")。先换形
331
- //(11px→calc(11px + delta))再走下方行距倍率,与正文 calc 形同一条放大路径。
332
- if (
333
- (name === '--dsw-font-markdown-code-block' || name === '--dsw-font-markdown-code-block-small') &&
334
- (next !== value || lineH !== null)
335
- ) {
336
- next = next
337
- .replace(/^(\d+(?:\.\d+)?)px/, 'calc($1px + var(--dsh-content-font-delta))')
338
- .replace(/(\/\s*)(\d+(?:\.\d+)?)px/, (m, lead, n) => lead + 'calc(' + n + 'px + var(--dsh-content-font-delta))')
339
- }
340
372
  // 行距:官方行高 token 有两种真实形态(简写里的 "/ 行高" 段,均出自 asar 实测)——
341
- // 正文/标题:"/ calc(Npx + var(--dsh-content-font-delta))"(增量由官方自管、随字号缩放)
342
- // 代码类: "/ Npx"(纯 px,不随字号)
343
- // 两种都按倍率放大;calc 形先行(其产物不含 "/数字px" 段,两步互不误伤)
373
+ // 正文/标题:"/ calc(Npx + var(--dsh-content-font-delta))"
374
+ // 代码类(0.2.2 起):"/ calc(Npx + var(--dsh-code-font-delta))"
375
+ // 其余旧式:"/ Npx"(纯 px)
376
+ // 增量函数的变量名按 --dsh-*-font-delta* 泛化匹配;calc 形先行(其产物不含 "/数字px" 段)。
344
377
  if (lineH !== null) {
345
378
  next = next
346
379
  .replace(
347
- /(\/\s*)calc\((\d+(?:\.\d+)?)px \+ var\(--dsh-content-font-delta\)\)/g,
348
- (m, lead, n) => lead + 'calc((' + n + 'px + var(--dsh-content-font-delta)) * ' + lineH + ')',
380
+ /(\/\s*)calc\((\d+(?:\.\d+)?)px \+ var\(--dsh-[a-z0-9-]*font-delta[a-z0-9-]*\)\)/g,
381
+ (m, lead, n) => lead + 'calc((' + n + 'px + var(--dsh-' + (m.includes('code-font-delta') ? 'code' : 'content') + '-font-delta' + (m.includes('font-delta-secondary') ? '-secondary' : '') + ')) * ' + lineH + ')',
349
382
  )
350
383
  .replace(/(\/\s*)(\d+(?:\.\d+)?)px/g, (m, lead, n) => lead + Math.round(Number(n) * lineH * 2) / 2 + 'px')
351
384
  }
@@ -379,53 +412,71 @@ window.__ModuleLoader__.load({
379
412
  if (!container) return null
380
413
  return { container, target: container.parentElement ?? container, column: container.offsetWidth }
381
414
  }
382
- /** 官方手柄拖动优先:把一个宽度状态应用到页面(state.widthPx 为准)。 */
383
- function applyWidthState() {
415
+ /** 记录“当前宽度是插件扩展”这一事实;切换时落盘,供下次启动区分扩展与官方镜像。 */
416
+ function markWidthExt(on) {
417
+ if (state.widthExt === on) return
418
+ state.widthExt = on
419
+ writeState({ widthExt: on })
420
+ }
421
+ /** 官方手柄拖动优先:把一个宽度状态应用到页面(state.widthPx 为准)。
422
+ * clearAuto:仅在用户主动选"自动"时清除官方键;启动/外部变化不动官方设置。 */
423
+ function applyWidthState(clearAuto) {
384
424
  const t = widthTargets()
385
425
  if (!t) return
386
426
  const { container, target, column } = t
427
+ if (!(column > 0)) return // 会话尚未排版:无可靠列宽,先不动官方值
387
428
  const omax = officialMaxFor(column)
388
429
  const cap = extCapFor(column)
389
430
  const px = state.widthPx
390
431
  if (px == null) {
391
- localStorage.removeItem(WIDTH_PREF_KEY)
392
- target.style.removeProperty('--dsh-chat-user-width')
432
+ if (clearAuto) {
433
+ localStorage.removeItem(WIDTH_PREF_KEY)
434
+ target.style.removeProperty('--dsh-chat-user-width')
435
+ }
393
436
  container.style.removeProperty('--dsh-chat-content-width')
437
+ markWidthExt(false)
394
438
  } else if (px <= omax) {
395
439
  container.style.removeProperty('--dsh-chat-content-width')
396
440
  localStorage.setItem(WIDTH_PREF_KEY, String(px))
397
441
  target.style.setProperty('--dsh-chat-user-width', px + 'px')
442
+ markWidthExt(false)
398
443
  } else {
399
444
  const w = Math.min(px, cap)
400
445
  localStorage.setItem(WIDTH_PREF_KEY, String(omax))
401
446
  target.style.setProperty('--dsh-chat-user-width', omax + 'px')
402
447
  container.style.setProperty('--dsh-chat-content-width', w + 'px')
448
+ markWidthExt(true)
403
449
  }
404
450
  }
405
451
  function currentWidthInfo() {
406
452
  const t = widthTargets()
407
- if (!t) return null
453
+ if (!t || !(t.column > 0)) return null
408
454
  const omax = officialMaxFor(t.column)
409
455
  const cap = extCapFor(t.column)
410
456
  const px = state.widthPx
411
457
  const extActive = px != null && px > omax
412
458
  let shown
459
+ let officialPx = null
413
460
  if (px != null) shown = extActive ? Math.min(px, cap) : px
414
461
  else {
415
462
  const raw = getComputedStyle(t.container).getPropertyValue('--dsh-chat-user-width').trim()
416
- shown = raw ? Number.parseFloat(raw) : resolveAutoWidth(t.column)
463
+ officialPx = raw ? Number.parseFloat(raw) : null
464
+ shown = Number.isFinite(officialPx) ? officialPx : resolveAutoWidth(t.column)
417
465
  }
418
- return { px: shown, auto: px == null, ext: extActive, column: t.column, min: CONTENT_MIN, max: cap }
466
+ return { px: shown, auto: px == null && !Number.isFinite(officialPx), ext: extActive, column: t.column, min: CONTENT_MIN, max: cap }
419
467
  }
420
468
  /** 漂移修复:官方重发布 / 窗口变化后把我们的状态归位(官方手柄拖动中不插手)。 */
421
469
  function normalizeWidthDrift() {
422
470
  if (officialDragging) return
423
471
  const t = widthTargets()
424
- if (!t) return
472
+ if (!t || !(t.column > 0)) return
425
473
  const omax = officialMaxFor(t.column)
426
474
  const cap = extCapFor(t.column)
427
475
  const px = state.widthPx
428
- if (px == null) return
476
+ if (px == null) {
477
+ markWidthExt(false)
478
+ return
479
+ }
429
480
  if (px > omax) {
430
481
  const w = Math.min(px, cap) + 'px'
431
482
  if (t.container.style.getPropertyValue('--dsh-chat-content-width') !== w) {
@@ -438,6 +489,7 @@ window.__ModuleLoader__.load({
438
489
  if (t.target.style.getPropertyValue('--dsh-chat-user-width') !== omax + 'px') {
439
490
  t.target.style.setProperty('--dsh-chat-user-width', omax + 'px')
440
491
  }
492
+ markWidthExt(true)
441
493
  } else {
442
494
  if (t.container.style.getPropertyValue('--dsh-chat-content-width')) {
443
495
  t.container.style.removeProperty('--dsh-chat-content-width')
@@ -448,13 +500,15 @@ window.__ModuleLoader__.load({
448
500
  if (t.target.style.getPropertyValue('--dsh-chat-user-width') !== px + 'px') {
449
501
  t.target.style.setProperty('--dsh-chat-user-width', px + 'px')
450
502
  }
503
+ markWidthExt(false)
451
504
  }
452
505
  }
453
506
 
454
507
  // ── 字号(官方直连 + 超范围缩放层) ─────────────────────────────────
455
- const fontState = { official: null }
508
+ const fontState = { official: null, code: null, codeFamily: '', textFamily: '', terminal: null, terminalFamily: '' }
456
509
  let fontSvc = null
457
- let fontSelfWrite = false
510
+ let roleSizeSupported = false // setFontSize(role, px) 角色形
511
+ let familySupported = false // setFontFamily(role, value)
458
512
  function fallbackBasePx() {
459
513
  // 官方把该变量写在 body 内联样式上(见上方常量注释);主题服务未就绪时的兜底
460
514
  const inline = Number.parseFloat(document.body.style.getPropertyValue(CONTENT_FONT_SIZE_VARIABLE))
@@ -472,19 +526,37 @@ window.__ModuleLoader__.load({
472
526
  root.style.setProperty('--dsrb-font-scale', String(scale))
473
527
  tag.disabled = Math.abs(scale - 1) < 0.001 // 无缩放 → 样式表彻底停用,回到官方原样
474
528
  }
475
- /** 写官方字号(带自我写入标记,避免 themes/change 回环打架)。 */
476
- function writeOfficialFont(px) {
529
+ /** 调官方角色字号(0.2.2+ 为 role,px;旧版单参仅 text 可用)。 */
530
+ function officialSetFontSize(role, px) {
477
531
  if (!fontSvc || typeof fontSvc.setFontSize !== 'function') return
478
- if (fontState.official === px) return
479
- fontSelfWrite = true
480
532
  try {
481
- fontSvc.setFontSize(px)
533
+ if (fontSvc.setFontSize.length >= 2) fontSvc.setFontSize(role, px)
534
+ else if (role === 'text') fontSvc.setFontSize(px)
482
535
  } catch {
483
- // 官方校验拒绝时忽略(理论不可达:调用处已夹在 10–22 内)
536
+ // 官方校验拒绝:调用处已夹在范围内,静默忽略
484
537
  }
485
- fontSelfWrite = false
538
+ }
539
+ /** 调官方角色字体族:value 为逗号分隔的列表,空串恢复默认栈。 */
540
+ function officialSetFontFamily(role, value) {
541
+ if (!fontSvc || typeof fontSvc.setFontFamily !== 'function') return
542
+ try {
543
+ fontSvc.setFontFamily(role, value)
544
+ } catch { /* 官方校验拒绝:静默忽略 */ }
545
+ }
546
+ /** 写官方正文字号(仅用户主动调节时调用)。 */
547
+ function writeOfficialFont(px) {
548
+ if (fontState.official === px) return
549
+ officialSetFontSize('text', px)
486
550
  }
487
551
  // ── 工具 ────────────────────────────────────────────────────────────
552
+ /** 清空容器:先显式 remove 元素,再兜底 textContent(仿真/真实 DOM 都彻底)。 */
553
+ function clearChildren(node) {
554
+ if (!node) return
555
+ for (const child of [...(node.children || [])]) {
556
+ try { if (typeof child.remove === 'function') child.remove() } catch { /* 忽略 */ }
557
+ }
558
+ node.textContent = ''
559
+ }
488
560
  function el(tag, className, text) {
489
561
  const node = document.createElement(tag)
490
562
  if (className) node.className = className
@@ -499,16 +571,274 @@ window.__ModuleLoader__.load({
499
571
  if (e.target === toggle) return // 键盘空格:走原生(change 监听里统一 apply)
500
572
  e.preventDefault()
501
573
  toggle.checked = !toggle.checked
574
+ toggle.setAttribute('aria-checked', toggle.checked ? 'true' : 'false')
502
575
  apply()
503
576
  })
504
577
  }
505
578
 
506
- // ── 插件本体 ────────────────────────────────────────────────────────
579
+ // ── 插件本体(官方客户端插件形态:React 槽位 + locale + 主题服务) ──────
580
+ // 官方指南:UI 通过 slot 贡献 React 组件,不向 document.body 追加应用;
581
+ // 文案走 Client locale 服务;样式只用 --dsw-alias-* / --dsw-elevation-* token。
582
+ let React = null
583
+ try { React = require('react') } catch { React = null }
584
+
585
+ const LOCALE_NS = 'dshHappyReader'
586
+ let activeLocaleNs = LOCALE_NS // 热重载时旧命名空间可能尚未注销:备用命名空间避免复用将失效的字典
587
+ const TEXTS = {
588
+ zh: {
589
+ panelTitle: 'Happy Reader', hintClose: '点面板外关闭', close: '关闭',
590
+ dragHint: '面板任意空白处可拖动 · 双击标题栏复位',
591
+ fabTitle: 'Happy Reader(拖动可移动)',
592
+ hideComposerTitle: '隐藏输入框', hideComposerSub: '收起底部输入区',
593
+ hideTopTitle: '隐藏上边栏', hideTopSub: '收起会话标题栏',
594
+ typeRowTitle: '行距 / 字距', typeRowSub: '默认 = 官方原样',
595
+ lineHeight: '行距 ', letterSpacing: '字距 ', defaultText: '默认',
596
+ fontSize: '字号 ', fontSizeSync: '与官方设置同步',
597
+ codeFontSizeSub: '与官方设置同步(10–16)',
598
+ textFontTitle: '正文字体',
599
+ cjkLabel: '中文', latinLabel: '西文',
600
+ codeFontTitle: '代码字体', codeFontSub: '与官方设置同步 · 仅显示本机已装字体',
601
+ widthTitle: '内容宽度 ', widthSub: '与官方手柄同步', widthAuto: '自动',
602
+ widthUnavailable: '—(打开会话后可用)',
603
+ fullscreenTitle: '全屏', fullscreenSub: '铺满屏幕 · Esc 退出',
604
+ fontDefaultOption: '默认(官方)', minus: '减小', plus: '增大',
605
+ splitTitle: '中英分设', splitSub: '中文 / 西文分别选择 · 本机字体',
606
+ textBaseTitle: '官方正文字体', textBaseSub: '基础字族;中英分设优先',
607
+ terminalFontTitle: '终端字体', terminalFontSub: '与官方设置同步 · 侧栏终端',
608
+ terminalFontSizeSub: '与官方设置同步(10–20)',
609
+ customFontOption: '自定义…', currentFontPrefix: '当前:', fontRowLabel: '字体', sizeRowLabel: '字号 ',
610
+ customFontPlaceholder: '字体名,可用逗号分隔多个',
611
+ groupSummarySplit: '中英分设', groupSummaryOfficial: '官方字体',
612
+ settingsCjkTitle: '正文字体 · 中文', settingsCjkDesc: '只作用于汉字与中文标点;仅显示本机已装字体',
613
+ settingsLatinTitle: '正文字体 · 西文', settingsLatinDesc: '只作用于字母、数字与西文标点;仅显示本机已装字体',
614
+ },
615
+ en: {
616
+ panelTitle: 'Happy Reader', hintClose: 'Click outside to close', close: 'Close',
617
+ dragHint: 'Drag any empty area to move · double-click the title to reset',
618
+ fabTitle: 'Happy Reader (drag to move)',
619
+ hideComposerTitle: 'Hide composer', hideComposerSub: 'Collapse the input area',
620
+ hideTopTitle: 'Hide title bar', hideTopSub: 'Collapse the session title bar',
621
+ typeRowTitle: 'Line & letter spacing', typeRowSub: 'Default = official',
622
+ lineHeight: 'Line ', letterSpacing: 'Letter ', defaultText: 'Default',
623
+ fontSize: 'Font size ', fontSizeSync: 'Synced with official settings',
624
+ codeFontSizeSub: 'Synced with official settings (10–16)',
625
+ textFontTitle: 'Body font',
626
+ cjkLabel: 'Chinese', latinLabel: 'Latin',
627
+ codeFontTitle: 'Code font', codeFontSub: 'Synced with official settings · installed fonts only',
628
+ widthTitle: 'Content width ', widthSub: 'Synced with the official handle', widthAuto: 'Auto',
629
+ widthUnavailable: '— (open a session first)',
630
+ fullscreenTitle: 'Fullscreen', fullscreenSub: 'Fill the screen · Esc to exit',
631
+ fontDefaultOption: 'Default (official)', minus: 'Decrease', plus: 'Increase',
632
+ splitTitle: 'Chinese / Latin split', splitSub: 'Pick each side separately · installed fonts',
633
+ textBaseTitle: 'Official body font', textBaseSub: 'Base family; the split takes precedence',
634
+ terminalFontTitle: 'Terminal font', terminalFontSub: 'Synced with official settings · sidebar terminal',
635
+ terminalFontSizeSub: 'Synced with official settings (10–20)',
636
+ customFontOption: 'Custom…', currentFontPrefix: 'Current: ', fontRowLabel: 'Font', sizeRowLabel: 'Size ',
637
+ customFontPlaceholder: 'Font names, comma separated',
638
+ groupSummarySplit: 'split', groupSummaryOfficial: 'official font',
639
+ settingsCjkTitle: 'Body font · Chinese', settingsCjkDesc: 'Applies to Han characters and Chinese punctuation only; installed fonts only',
640
+ settingsLatinTitle: 'Body font · Latin', settingsLatinDesc: 'Applies to letters, digits and Latin punctuation only; installed fonts only',
641
+ },
642
+ }
643
+ function fallbackT(key) {
644
+ return TEXTS.zh[key] ?? key
645
+ }
646
+ let t = fallbackT
647
+
648
+ // 挂载句柄(槽位宿主 ref ↔ 实体生命周期)
649
+ let ui = null
650
+ let hostNode = null
651
+ let hostDispose = null
652
+ let overlayAlive = false
653
+ let settingsHost = null
654
+ let settingsDispose = null
655
+ let settingsRefresh = null
656
+ let settingsAlive = false
657
+ let remounting = false // 语言切换等就地重挂:不算插件停用,不退出全屏
658
+
659
+ /** 官方主题快照 → 字号/字体族桥接(0.2.2 角色形与旧版单值形都读)。 */
660
+ function syncTheme(snap) {
661
+ const sizes = snap && snap.fontSizes
662
+ const families = snap && snap.fontFamilies
663
+ const legacy = snap && typeof snap.fontSize === 'number' ? snap.fontSize : null
664
+ const text = sizes && typeof sizes.text === 'number' ? sizes.text : legacy
665
+ if (typeof text === 'number' && text > 0) fontState.official = text
666
+ if (sizes && typeof sizes.code === 'number') {
667
+ fontState.code = sizes.code
668
+ } else {
669
+ const inlineCode = Number.parseFloat(document.body.style.getPropertyValue('--dsh-code-font-size'))
670
+ if (Number.isFinite(inlineCode) && inlineCode > 0) fontState.code = Math.round(inlineCode)
671
+ }
672
+ if (sizes && typeof sizes.terminal === 'number') {
673
+ fontState.terminal = sizes.terminal
674
+ } else {
675
+ const inlineTerm = Number.parseFloat(document.body.style.getPropertyValue('--dsh-terminal-font-size'))
676
+ if (Number.isFinite(inlineTerm) && inlineTerm > 0) fontState.terminal = Math.round(inlineTerm)
677
+ }
678
+ const prevTextFamily = fontState.textFamily
679
+ if (families && typeof families.text === 'string') fontState.textFamily = families.text
680
+ if (families && typeof families.code === 'string') fontState.codeFamily = families.code
681
+ if (families && typeof families.terminal === 'string') fontState.terminalFamily = families.terminal
682
+ const prevPref = lastThemePreference
683
+ if (snap && typeof snap.preference === 'string') lastThemePreference = snap.preference
684
+ const themeSwitched = prevPref !== undefined && lastThemePreference !== prevPref
685
+ // 官方字族或主题切换后,旧 token 缓存作废
686
+ if (prevTextFamily !== fontState.textFamily || themeSwitched) themeFontDecls = null
687
+ // 官方是 10–22px 的权威来源:启动/外部变化时只采纳官方值,绝不反写;
688
+ // 超出 22 的绝对目标保留,由缩放层相对官方基准适配。
689
+ const prevFontPx = state.fontPx
690
+ if (typeof fontState.official === 'number') {
691
+ if (typeof state.fontPx !== 'number') state.fontPx = fontState.official
692
+ else if (state.fontPx <= FONT_OFFICIAL_MAX) state.fontPx = fontState.official
693
+ }
694
+ if (state.fontPx !== prevFontPx) writeState({ fontPx: state.fontPx })
695
+ if (ui) ui.renderFontRow()
696
+ refreshThemeUi(prevTextFamily, themeSwitched)
697
+ }
698
+
699
+ /** 主题/字号变化后刷新已挂载的 UI(未挂载时零开销)。 */
700
+ function refreshThemeUi(prevTextFamily, themeSwitched) {
701
+ if (!ui) return
702
+ ui.renderCodeSizeRow()
703
+ ui.renderCodeFontRow()
704
+ ui.renderTerminalSizeRow()
705
+ ui.renderTerminalFontRow()
706
+ ui.renderTextBaseRow()
707
+ ui.applyFont()
708
+ if (prevTextFamily !== fontState.textFamily || themeSwitched) ui.refreshFontPick()
709
+ ui.renderGroupSummaries()
710
+ }
711
+
712
+ /** 槽位组件:零尺寸宿主 div;实体在 ref 挂载时创建(不触碰 document.body)。 */
713
+ function HappyReaderOverlay() {
714
+ return React.createElement('div', { className: 'dsrb-slot-host', ref: overlayHostRef })
715
+ }
716
+ function overlayHostRef(node) {
717
+ if (node) {
718
+ hostNode = node
719
+ if (!hostDispose) hostDispose = mount(node)
720
+ } else if (hostDispose) {
721
+ try { hostDispose() } catch { /* 拆除失败不阻塞 */ }
722
+ hostDispose = null
723
+ hostNode = null
724
+ }
725
+ }
726
+ function remount() {
727
+ remounting = true
728
+ try {
729
+ if (overlayAlive && hostNode) {
730
+ if (hostDispose) { try { hostDispose() } catch { /* 忽略 */ } }
731
+ hostDispose = mount(hostNode)
732
+ }
733
+ if (settingsAlive && settingsHost) {
734
+ if (settingsDispose) { try { settingsDispose() } catch { /* 忽略 */ } }
735
+ settingsDispose = mountSettingsRows(settingsHost)
736
+ }
737
+ } finally {
738
+ remounting = false
739
+ }
740
+ }
741
+
742
+ /** 本机已装字体(三套候选统一检测一次,面板与官方设置行共用)。 */
743
+ let installedCache = null
744
+ function installedFonts(role) {
745
+ if (!installedCache) {
746
+ installedCache = {
747
+ cjk: TEXT_FONTS_CJK.filter((f) => fontAvailable(f.name)),
748
+ latin: TEXT_FONTS_LATIN.filter((f) => fontAvailable(f.name)),
749
+ code: CODE_FONTS.filter((f) => fontAvailable(f.name)),
750
+ }
751
+ fontDetectCtx = null // 检测完成即释放 canvas 上下文
752
+ }
753
+ if (role === 'text') {
754
+ if (!installedCache.text) {
755
+ installedCache.text = installedCache.cjk.concat(
756
+ installedCache.latin.filter((f) => !installedCache.cjk.some((c) => c.name === f.name)),
757
+ )
758
+ }
759
+ return installedCache.text
760
+ }
761
+ return installedCache[role] || []
762
+ }
763
+ /** 下拉:默认(官方)+ 本机已装候选。 */
764
+ function fillFontOptions(select, list) {
765
+ clearChildren(select)
766
+ const def = el('option', null, t('fontDefaultOption'))
767
+ def.value = ''
768
+ select.append(def)
769
+ for (const f of list) {
770
+ const opt = el('option', null, f.label)
771
+ opt.value = f.id
772
+ select.append(opt)
773
+ }
774
+ }
775
+
776
+ /** 正文字体:两处 UI(浮动面板 / 官方通用设置)共用的唯一写入口。 */
777
+ function commitTextFont(role, id) {
778
+ const next = id && fontChoice(installedFonts(role), id) ? id : null
779
+ if (role === 'cjk') state.textFontCjk = next
780
+ else state.textFontLatin = next
781
+ writeState({ textFontCjk: state.textFontCjk, textFontLatin: state.textFontLatin })
782
+ if (ui && typeof ui.applyTextFonts === 'function') ui.applyTextFonts()
783
+ if (settingsRefresh) settingsRefresh()
784
+ }
785
+
786
+ /** 官方「通用设置」里的两行正文字体(settings.general.item 槽位组件)。 */
787
+ function HappyReaderSettingsFonts() {
788
+ return React.createElement('div', { className: 'dsrb-settings', ref: settingsHostRef })
789
+ }
790
+ function settingsHostRef(node) {
791
+ if (node) {
792
+ settingsHost = node
793
+ if (!settingsDispose) settingsDispose = mountSettingsRows(node)
794
+ } else {
795
+ if (settingsDispose) { try { settingsDispose() } catch { /* 拆除失败不阻塞 */ } }
796
+ settingsDispose = null
797
+ settingsHost = null
798
+ }
799
+ }
800
+ /** 按官方 FontFamilyRow 的行距/字号/分隔线规格构建两行下拉;返回清理函数。 */
801
+ function mountSettingsRows(host) {
802
+ try {
803
+ settingsAlive = true
804
+ clearChildren(host)
805
+ const makeRow = (role, titleKey, descKey) => {
806
+ const row = el('div', 'dsrb-set-row')
807
+ const text = el('div', 'dsrb-set-text')
808
+ text.append(el('div', 'dsrb-set-title', t(titleKey)), el('div', 'dsrb-set-desc', t(descKey)))
809
+ const select = el('select', 'dsrb-set-select')
810
+ select.setAttribute('aria-label', t(titleKey))
811
+ fillFontOptions(select, installedFonts(role))
812
+ select.value = (role === 'cjk' ? state.textFontCjk : state.textFontLatin) ?? ''
813
+ select.addEventListener('change', () => commitTextFont(role, select.value))
814
+ row.append(text, select)
815
+ return { row, select }
816
+ }
817
+ const cjk = makeRow('cjk', 'settingsCjkTitle', 'settingsCjkDesc')
818
+ const latin = makeRow('latin', 'settingsLatinTitle', 'settingsLatinDesc')
819
+ host.append(cjk.row, latin.row)
820
+ settingsRefresh = () => {
821
+ cjk.select.value = state.textFontCjk ?? ''
822
+ latin.select.value = state.textFontLatin ?? ''
823
+ }
824
+ return () => {
825
+ settingsAlive = false
826
+ settingsRefresh = null
827
+ clearChildren(host)
828
+ }
829
+ } catch (err) {
830
+ console.warn('[dsh-happy-reader] 设置行初始化失败:', err)
831
+ settingsAlive = false
832
+ settingsRefresh = null
833
+ try { clearChildren(host) } catch { /* 忽略 */ }
834
+ return () => {}
835
+ }
836
+ }
837
+
507
838
  function apply(ctx) {
508
839
  try {
509
840
  // 热重载:DSH 监视插件文件变化,会再次执行本客户端。先拆旧实例、再按 id
510
- // 清扫遗留节点、最后重建 —— 页面上运行的必然是最新代码(旧实例的监听闭包
511
- // 随节点删除失联、残留定时器空转,均无害)。
841
+ // 清扫遗留节点、最后重建 —— 页面上运行的必然是最新代码。
512
842
  const prevDispose = window.__dsrbDispose
513
843
  if (typeof prevDispose === 'function') {
514
844
  try { prevDispose() } catch { /* 旧实例拆除失败也不阻塞重建 */ }
@@ -517,10 +847,186 @@ window.__ModuleLoader__.load({
517
847
  const staleNode = document.getElementById(staleId)
518
848
  if (staleNode) staleNode.remove()
519
849
  }
520
- if (document.getElementById(FAB_ID)) return // 拆除后仍在=异常态,守住不重复挂载
850
+ // 旧外壳回退宿主没有固定 id:按专属类名清扫(槽位宿主由 React 自己卸载)
851
+ for (const staleHost of document.querySelectorAll('.dsrb-slot-host.dsrb-standalone')) staleHost.remove()
852
+ // 热重载时旧模块的设置页行可能还在宿主里:按行清扫,避免叠加成四行
853
+ for (const staleRow of document.querySelectorAll('.dsrb-set-row')) staleRow.remove()
521
854
 
522
855
  state = readState()
856
+ // ── 启动时应用已保存的功能状态(主题同步先行使用,故放在最前) ──
857
+ state.open = !!state.open
858
+ state.hideComposer = !!state.hideComposer
859
+ state.hideTop = !!state.hideTop
860
+ state.x = typeof state.x === 'number' ? state.x : null
861
+ state.y = typeof state.y === 'number' ? state.y : null
862
+ if (typeof state.fontPx === 'number') {
863
+ state.fontPx = Math.min(FONT_MAX, Math.max(FONT_MIN, Math.round(state.fontPx)))
864
+ } else {
865
+ state.fontPx = null
866
+ }
867
+ if (typeof state.widthPx !== 'number' || !(state.widthPx > 0)) state.widthPx = null
868
+ state.widthExt = typeof state.widthExt === 'boolean' ? state.widthExt : null // null=旧版状态,启动时推断
869
+ if (typeof state.lineHeight === 'number') {
870
+ const lh = Math.min(LINEH_MAX, Math.max(LINEH_MIN, Math.round(state.lineHeight * 10) / 10))
871
+ state.lineHeight = lh > LINEH_MIN ? lh : null
872
+ } else state.lineHeight = null
873
+ if (typeof state.letterSpacing === 'number') {
874
+ const ls = Math.min(LETTERSP_MAX, Math.max(LETTERSP_MIN, Math.round(state.letterSpacing * 10) / 10))
875
+ state.letterSpacing = ls !== 0 ? ls : null
876
+ } else state.letterSpacing = null
877
+ state.panelX = typeof state.panelX === 'number' ? state.panelX : null
878
+ state.panelY = typeof state.panelY === 'number' ? state.panelY : null
879
+ state.panelH = typeof state.panelH === 'number' && state.panelH > 0 ? state.panelH : null
880
+ // 字体折叠组展开态:默认三行全部收起(打开面板先看到三行),点哪行展开哪行;记忆用户选择
881
+ if (state.groupsVersion !== GROUPS_VERSION) {
882
+ state.groups = { text: false, code: false, terminal: false }
883
+ state.groupsVersion = GROUPS_VERSION
884
+ writeState({ groups: state.groups, groupsVersion: GROUPS_VERSION })
885
+ } else {
886
+ if (!state.groups || typeof state.groups !== 'object') state.groups = {}
887
+ state.groups.text = typeof state.groups.text === 'boolean' ? state.groups.text : false
888
+ state.groups.code = typeof state.groups.code === 'boolean' ? state.groups.code : false
889
+ state.groups.terminal = typeof state.groups.terminal === 'boolean' ? state.groups.terminal : false
890
+ }
891
+
892
+ // ── 本地化:注册字典(owning effect)+ 绑定 t;语言切换时重挂实体刷新文案 ──
893
+ ctx.inject(['locale'], (lctx) => {
894
+ const loc = lctx.locale
895
+ const dicts = { zh: TEXTS.zh, en: TEXTS.en }
896
+ let registered = false
897
+ let regErr = null
898
+ try {
899
+ lctx.effect(() => loc.register(LOCALE_NS, dicts), 'dsh-happy-reader: 语言字典')
900
+ registered = true
901
+ } catch (err) {
902
+ // 热重载时旧命名空间可能尚在:换一个本实例独占的命名空间重新注册,
903
+ // 避免绑定到随后会被旧 effect 注销的旧字典
904
+ regErr = err
905
+ const alt = LOCALE_NS + '-' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6)
906
+ try {
907
+ lctx.effect(() => loc.register(alt, dicts), 'dsh-happy-reader: 语言字典(备用)')
908
+ activeLocaleNs = alt
909
+ registered = true
910
+ regErr = null
911
+ } catch { /* 保持 regErr,下面探测旧字典 */ }
912
+ }
913
+ let bound = null
914
+ try {
915
+ if (typeof loc.bind === 'function') bound = loc.bind(activeLocaleNs)
916
+ } catch { /* 视为不可用 */ }
917
+ let usable = registered && typeof bound === 'function'
918
+ if (!usable && typeof bound === 'function' && activeLocaleNs !== LOCALE_NS) {
919
+ try {
920
+ bound = loc.bind(LOCALE_NS)
921
+ usable = typeof bound === 'function' && bound('panelTitle') !== 'panelTitle'
922
+ } catch { /* 视为不可用 */ }
923
+ }
924
+ if (usable) {
925
+ t = bound
926
+ if (!registered) console.warn('[dsh-happy-reader] 语言字典沿用旧命名空间(可能在旧实例注销后失效):', regErr || '')
927
+ } else {
928
+ console.warn('[dsh-happy-reader] 语言服务不可用(沿用内置中文):', regErr || 'bind failed')
929
+ }
930
+ remount() // 服务晚到时刷新已挂载文案(启动时尚未挂载则无事发生)
931
+ lctx.on('locale/change', () => remount())
932
+ })
933
+
934
+ // ── 主题服务:字号/字体族桥接(官方接口优先) ──────────────────
935
+ ctx.inject(['theme'], (tctx) => {
936
+ fontSvc = tctx.theme
937
+ // 能力按接口形状判定:即使首读快照异常,控件仍可用(写入失败由官方静默拒绝)
938
+ roleSizeSupported = !!(fontSvc && typeof fontSvc.setFontSize === 'function' && fontSvc.setFontSize.length >= 2)
939
+ familySupported = !!(fontSvc && typeof fontSvc.setFontFamily === 'function')
940
+ tctx.on('theme/change', (snap) => syncTheme(snap))
941
+ try {
942
+ syncTheme(tctx.theme.getTheme())
943
+ } catch {
944
+ if (ui) ui.applyFont() // 服务首读异常时至少按兜底基准绘制一次
945
+ }
946
+ })
523
947
 
948
+ // ── 官方会话列表事件:会话切换后重新定位/应用宽度(不轮询 DOM) ──
949
+ ctx.inject(['sessions'], (sctx) => {
950
+ const list = sctx.sessions && sctx.sessions.list
951
+ if (!list || typeof list.subscribe !== 'function') return
952
+ try {
953
+ sctx.effect(() => list.subscribe(() => {
954
+ if (ui && ui.scheduleWidthRefresh) ui.scheduleWidthRefresh()
955
+ }), 'dsh-happy-reader: 会话切换复核宽度')
956
+ } catch (err) {
957
+ console.warn('[dsh-happy-reader] 会话订阅失败(宽度仅在面板/窗口事件时复核):', err)
958
+ }
959
+ })
960
+
961
+ // ── 渲染面:官方 shell.overlay 槽位(框架级覆盖层,pointer-events:none) ──
962
+ let registered = false
963
+ let standalone = null // 旧外壳回退时直挂 body 的宿主,卸载时一并移除
964
+ if (React && ctx.slots && typeof ctx.slots.inject === 'function') {
965
+ try {
966
+ ctx.slots.inject('shell.overlay', () => ctx.slots.register({
967
+ name: 'shell.overlay',
968
+ id: 'dsh-happy-reader',
969
+ order: -1, // 排在官方 overlay 条目之前:右键菜单/提示条等始终盖在插件之上
970
+ locale: activeLocaleNs,
971
+ }, HappyReaderOverlay))
972
+ registered = true
973
+ } catch (err) {
974
+ console.warn('[dsh-happy-reader] 槽位注册失败,回退直挂:', err)
975
+ }
976
+ }
977
+ if (registered) {
978
+ // 官方「通用设置 → 字号大小」组下方增加两行正文字体(中文 / 西文)。
979
+ // settings.general.item 是官方列表槽位;旧外壳无此槽位时注册被忽略。
980
+ try {
981
+ ctx.slots.inject('settings.general.item', () => ctx.slots.register({
982
+ name: 'settings.general.item',
983
+ id: 'dsh-happy-reader.fonts',
984
+ order: 11.5,
985
+ locale: activeLocaleNs,
986
+ }, HappyReaderSettingsFonts))
987
+ } catch (err) {
988
+ console.warn('[dsh-happy-reader] 设置行注册失败(旧版外壳忽略):', err)
989
+ }
990
+ }
991
+ if (!registered) {
992
+ // 旧版/无槽位宿主:宿主 div 退回直挂 body(UI 仍包在宿主内,不逐个裸挂)
993
+ standalone = el('div', 'dsrb-slot-host dsrb-standalone')
994
+ document.body.appendChild(standalone)
995
+ hostNode = standalone // 回退路径同样支持语言切换时就地重挂
996
+ hostDispose = mount(standalone)
997
+ }
998
+
999
+ ctx.effect(() => () => {
1000
+ if (hostDispose) {
1001
+ try { hostDispose() } catch { /* 忽略 */ }
1002
+ hostDispose = null
1003
+ }
1004
+ // 插件被禁用/卸载时,设置页里的插件行也必须立即消失(不等 React 迟到的卸载)
1005
+ if (settingsDispose) {
1006
+ try { settingsDispose() } catch { /* 忽略 */ }
1007
+ settingsDispose = null
1008
+ }
1009
+ settingsHost = null
1010
+ settingsRefresh = null
1011
+ settingsAlive = false
1012
+ if (standalone) {
1013
+ standalone.remove()
1014
+ standalone = null
1015
+ }
1016
+ // 不清空 window.__dsrbDispose:若新实例已接管,这里清空会破坏其热重载拆除句柄;
1017
+ // 旧句柄本身是幂等的,下次热重载再调用也安全。
1018
+ }, 'dsh-happy-reader: 槽位实体')
1019
+ } catch (err) {
1020
+ // 任何意外都只记日志,不影响应用本体
1021
+ console.error('[dsh-happy-reader] 挂载失败:', err)
1022
+ }
1023
+ }
1024
+
1025
+ /** 实体:构建面板/按钮/监听/定时器并把它们放进槽位宿主;返回清理函数。 */
1026
+ function mount(host) {
1027
+ let disposed = false
1028
+ overlayAlive = true
1029
+ try {
524
1030
  // 1) 样式表:基础外观 + 各功能覆盖层
525
1031
  const style = el('style')
526
1032
  style.id = STYLE_ID
@@ -539,7 +1045,7 @@ window.__ModuleLoader__.load({
539
1045
  const fsTag = el('style') // 全屏补偿层(常驻;仅存在全屏标记时才有匹配规则生效)
540
1046
  fsTag.id = FS_STYLE_ID
541
1047
  fsTag.textContent = FS_CSS
542
- document.head.append(style, hideTag, hideTopTag, fontTag, fontPickTag, fsTag)
1048
+ host.append(style, hideTag, hideTopTag, fontTag, fontPickTag, fsTag)
543
1049
  // disabled 必须在挂入文档后再设:未连接的 <style> 没有关联样式表,挂载前设置
544
1050
  // 会被静默丢弃(CSSOM 行为),否则隐藏层将无视已保存状态(字体层由 rebuildFontPick 按需启用)。
545
1051
  hideTag.disabled = !state.hideComposer
@@ -550,7 +1056,8 @@ window.__ModuleLoader__.load({
550
1056
  const fab = el('button', 'dsrb-fab', 'Aa')
551
1057
  fab.id = FAB_ID
552
1058
  fab.type = 'button'
553
- fab.title = 'Happy Reader(拖动可移动)'
1059
+ fab.title = t('fabTitle')
1060
+ fab.setAttribute('aria-label', t('fabTitle'))
554
1061
  const [fx, fy] = clampPos(
555
1062
  state.x ?? window.innerWidth - FAB_SIZE - 16,
556
1063
  state.y ?? window.innerHeight - FAB_SIZE - 16,
@@ -559,148 +1066,305 @@ window.__ModuleLoader__.load({
559
1066
  )
560
1067
  fab.style.left = fx + 'px'
561
1068
  fab.style.top = fy + 'px'
562
- document.body.appendChild(fab)
1069
+ host.appendChild(fab)
563
1070
 
564
1071
  // 3) 面板
565
1072
  const panel = el('div', 'dsrb-panel')
566
1073
  panel.id = PANEL_ID
1074
+ panel.setAttribute('role', 'region')
1075
+ panel.setAttribute('aria-label', t('panelTitle'))
567
1076
 
568
1077
  const headRow = el('div', 'dsrb-head')
569
- const title = el('h4')
570
- title.append('Happy Reader')
571
- const headHint = el('span', 'dsrb-headHint', '点面板外关闭')
1078
+ const title = el('h4', null, t('panelTitle'))
1079
+ const headHint = el('span', 'dsrb-headHint', t('hintClose'))
572
1080
  const closeBtn = el('button', 'dsrb-x', '×')
573
1081
  closeBtn.type = 'button'
574
- closeBtn.title = '关闭'
1082
+ closeBtn.title = t('close')
1083
+ closeBtn.setAttribute('aria-label', t('close'))
575
1084
  headRow.append(title, headHint, closeBtn)
576
1085
 
577
- // 行:隐藏输入框(整条可点)
578
- const rowHide = el('div', 'dsrb-row dsrb-row-click')
579
- const switchBox = el('span', 'dsrb-switch') // 用 span 而非 label:没有原生转发激活面
580
- const hideToggle = el('input')
581
- hideToggle.type = 'checkbox'
582
- switchBox.append(hideToggle, el('span'))
583
- const hideMain = el('div', 'dsrb-rowMain')
584
- hideMain.append(el('div', 'dsrb-rowTitle', '隐藏输入框'), el('div', 'dsrb-rowSub', '收起底部输入区'))
585
- rowHide.append(switchBox, hideMain)
586
-
587
- // 行:隐藏上边栏(整条可点)
588
- const rowTop = el('div', 'dsrb-row dsrb-row-click')
589
- const topBox = el('span', 'dsrb-switch')
590
- const topToggle = el('input')
591
- topToggle.type = 'checkbox'
592
- topBox.append(topToggle, el('span'))
593
- const topMain = el('div', 'dsrb-rowMain')
594
- topMain.append(el('div', 'dsrb-rowTitle', '隐藏上边栏'), el('div', 'dsrb-rowSub', '收起会话标题栏'))
595
- rowTop.append(topBox, topMain)
1086
+ /** 开关行:官方 36×20 视觉 + role=switch;整条可点由 bindRowToggle 绑定。 */
1087
+ function makeSwitchRow(titleText, subText) {
1088
+ const row = el('div', 'dsrb-row dsrb-row-click')
1089
+ const box = el('span', 'dsrb-switch') // span 而非 label:避免原生转发激活面
1090
+ const input = el('input')
1091
+ input.type = 'checkbox'
1092
+ input.setAttribute('role', 'switch')
1093
+ input.setAttribute('aria-checked', 'false')
1094
+ input.setAttribute('aria-label', titleText)
1095
+ box.append(input, el('span'))
1096
+ const main = el('div', 'dsrb-rowMain')
1097
+ main.append(el('div', 'dsrb-rowTitle', titleText), el('div', 'dsrb-rowSub', subText))
1098
+ row.append(box, main)
1099
+ return { row, input }
1100
+ }
1101
+ const { row: rowHide, input: hideToggle } = makeSwitchRow(t('hideComposerTitle'), t('hideComposerSub'))
1102
+
1103
+ const { row: rowTop, input: topToggle } = makeSwitchRow(t('hideTopTitle'), t('hideTopSub'))
596
1104
 
597
1105
  // 行:行距 / 字距(面板最下方;默认 = 官方原样)
598
1106
  const rowType = el('div', 'dsrb-row')
599
1107
  const typeMain = el('div', 'dsrb-rowMain')
600
- typeMain.append(el('div', 'dsrb-rowTitle', '行距 / 字距'), el('div', 'dsrb-rowSub', '默认 = 官方原样'))
1108
+ typeMain.append(el('div', 'dsrb-rowTitle', t('typeRowTitle')), el('div', 'dsrb-rowSub', t('typeRowSub')))
601
1109
  const typePair = el('div', 'dsrb-pair')
602
1110
  const lhCol = el('div', 'dsrb-pairCol')
603
1111
  const lhMini = el('div', 'dsrb-mini')
604
- const lhVal = el('b', null, '默认')
605
- lhMini.append('行距 ', lhVal)
1112
+ const lhVal = el('b', null, t('defaultText'))
1113
+ lhMini.append(t('lineHeight'), lhVal)
606
1114
  const lhStepper = el('div', 'dsrb-stepper')
607
1115
  const lhMinus = el('button', null, '−')
608
1116
  const lhPlus = el('button', null, '+')
609
1117
  lhMinus.type = lhPlus.type = 'button'
1118
+ lhMinus.setAttribute('aria-label', t('minus'))
1119
+ lhPlus.setAttribute('aria-label', t('plus'))
610
1120
  lhStepper.append(lhMinus, lhPlus)
611
1121
  lhCol.append(lhMini, lhStepper)
612
1122
  const lsCol = el('div', 'dsrb-pairCol')
613
1123
  const lsMini = el('div', 'dsrb-mini')
614
- const lsVal = el('b', null, '默认')
615
- lsMini.append('字距 ', lsVal)
1124
+ const lsVal = el('b', null, t('defaultText'))
1125
+ lsMini.append(t('letterSpacing'), lsVal)
616
1126
  const lsStepper = el('div', 'dsrb-stepper')
617
1127
  const lsMinus = el('button', null, '−')
618
1128
  const lsPlus = el('button', null, '+')
619
1129
  lsMinus.type = lsPlus.type = 'button'
1130
+ lsMinus.setAttribute('aria-label', t('minus'))
1131
+ lsPlus.setAttribute('aria-label', t('plus'))
620
1132
  lsStepper.append(lsMinus, lsPlus)
621
1133
  lsCol.append(lsMini, lsStepper)
622
1134
  typePair.append(lhCol, lsCol)
623
1135
  typeMain.append(typePair)
624
1136
  rowType.append(typeMain)
625
1137
 
626
- // 行:字号
627
- const rowFont = el('div', 'dsrb-row')
628
- const fontMain = el('div', 'dsrb-rowMain')
629
- const fontTitle = el('div', 'dsrb-rowTitle')
630
- const fontVal = el('b', null, '…')
631
- fontTitle.append('字号 ', fontVal)
632
- fontMain.append(fontTitle, el('div', 'dsrb-rowSub', '与官方设置同步'))
633
- const stepper = el('div', 'dsrb-stepper')
634
- const fontMinus = el('button', null, '−')
635
- const fontPlus = el('button', null, '+')
636
- fontMinus.type = fontPlus.type = 'button'
637
- stepper.append(fontMinus, fontPlus)
638
- rowFont.append(fontMain, stepper)
639
-
640
- // 行:正文字体(中文、西文两个独立下拉压缩在同一行;中文下拉只接管中日韩
1138
+ // ── 字体设置折叠组:正文字体 / 代码字体 / 终端字体 ──────────────
1139
+ const groups = {}
1140
+ function makeGroup(key, titleKey) {
1141
+ const group = el('div', 'dsrb-group')
1142
+ const head = el('button', 'dsrb-groupHead')
1143
+ head.type = 'button'
1144
+ head.setAttribute('aria-expanded', 'false')
1145
+ const title = el('span', 'dsrb-groupTitle', t(titleKey))
1146
+ const sub = el('span', 'dsrb-groupSub')
1147
+ const chev = el('span', 'dsrb-chevron')
1148
+ chev.setAttribute('aria-hidden', 'true')
1149
+ head.append(title, sub, chev)
1150
+ const body = el('div', 'dsrb-groupBody')
1151
+ body.id = 'dsrb-group-body-' + key
1152
+ head.setAttribute('aria-controls', body.id)
1153
+ group.append(head, body)
1154
+ head.addEventListener('click', () => toggleGroup(key))
1155
+ groups[key] = { group, head, body, sub }
1156
+ return groups[key]
1157
+ }
1158
+ function applyGroupState() {
1159
+ for (const key of Object.keys(groups)) {
1160
+ const open = !!state.groups[key]
1161
+ groups[key].body.style.display = open ? '' : 'none'
1162
+ groups[key].head.setAttribute('aria-expanded', open ? 'true' : 'false')
1163
+ }
1164
+ }
1165
+ function toggleGroup(key) {
1166
+ const open = !state.groups[key]
1167
+ const keepScroll = panelBody.scrollTop
1168
+ state.groups[key] = open
1169
+ applyGroupState()
1170
+ writeState({ groups: state.groups })
1171
+ if (state.open) placePanel(keepScroll)
1172
+ }
1173
+ makeGroup('text', 'textFontTitle')
1174
+ makeGroup('code', 'codeFontTitle')
1175
+ makeGroup('terminal', 'terminalFontTitle')
1176
+
1177
+ // 角色字体行:本机已装字体下拉 + “自定义…”文本框(官方逗号列表同款能力)。
1178
+ // 官方值不在候选表时显示“当前:…”,绝不静默误报为默认。
1179
+ const FAMILY_KEY = { text: 'textFamily', code: 'codeFamily', terminal: 'terminalFamily' }
1180
+ function makeRoleFontPicker(role, titleKey, subKey, extraClass, ariaKey) {
1181
+ const row = el('div', 'dsrb-row')
1182
+ const main = el('div', 'dsrb-rowMain')
1183
+ main.append(el('div', 'dsrb-rowTitle', t(titleKey)), el('div', 'dsrb-rowSub', t(subKey)))
1184
+ const select = el('select', 'dsrb-select ' + extraClass)
1185
+ const ariaName = t(ariaKey || titleKey)
1186
+ select.setAttribute('aria-label', ariaName)
1187
+ const input = el('input', 'dsrb-input ' + extraClass + '-custom')
1188
+ input.type = 'text'
1189
+ input.setAttribute('aria-label', ariaName)
1190
+ input.placeholder = t('customFontPlaceholder')
1191
+ input.spellcheck = false
1192
+ input.autocomplete = 'off'
1193
+ input.maxLength = 200
1194
+ input.style.display = 'none'
1195
+ main.append(select, input)
1196
+ row.append(main)
1197
+ const picker = { row, select, input, role, list: [] }
1198
+ const customOpt = el('option', null, t('customFontOption'))
1199
+ customOpt.value = '__custom__'
1200
+ let currentOpt = null
1201
+ const clearCurrent = () => {
1202
+ if (!currentOpt) return
1203
+ if (typeof currentOpt.remove === 'function') currentOpt.remove()
1204
+ currentOpt = null
1205
+ }
1206
+ picker.fill = (list) => {
1207
+ picker.list = list
1208
+ fillFontOptions(select, list)
1209
+ select.append(customOpt)
1210
+ picker.render()
1211
+ }
1212
+ picker.setEnabled = (on) => {
1213
+ select.disabled = !on
1214
+ input.disabled = !on
1215
+ if (!on) {
1216
+ input.style.display = 'none'
1217
+ select.value = ''
1218
+ }
1219
+ }
1220
+ picker.render = () => {
1221
+ if (input.style.display !== 'none' && document.activeElement === input) return
1222
+ const value = String(fontState[FAMILY_KEY[role]] || '')
1223
+ if (!value) {
1224
+ clearCurrent()
1225
+ select.value = ''
1226
+ return
1227
+ }
1228
+ const parts = value.split(',').map((x) => x.trim()).filter(Boolean)
1229
+ const first = parts[0] ? parts[0].replace(/^"|"$/g, '') : ''
1230
+ const hit = parts.length === 1 ? picker.list.find((f) => fontMatches(f, first)) : null
1231
+ if (hit) {
1232
+ clearCurrent()
1233
+ select.value = hit.id
1234
+ return
1235
+ }
1236
+ if (!currentOpt) {
1237
+ currentOpt = el('option', null, '')
1238
+ currentOpt.value = '__current__'
1239
+ select.append(currentOpt)
1240
+ }
1241
+ currentOpt.textContent = t('currentFontPrefix') + value
1242
+ select.value = '__current__'
1243
+ }
1244
+ select.addEventListener('change', () => {
1245
+ const v = select.value
1246
+ if (v === '__custom__') {
1247
+ input.value = String(fontState[FAMILY_KEY[role]] || '')
1248
+ input.style.display = ''
1249
+ if (typeof input.focus === 'function') input.focus()
1250
+ return
1251
+ }
1252
+ if (v === '__current__') {
1253
+ picker.render()
1254
+ return
1255
+ }
1256
+ const entry = fontChoice(picker.list, v)
1257
+ officialSetFontFamily(role, entry ? entry.name : '')
1258
+ picker.render()
1259
+ })
1260
+ const commit = () => {
1261
+ if (input.style.display === 'none') return
1262
+ input.style.display = 'none'
1263
+ officialSetFontFamily(role, input.value.trim())
1264
+ picker.render()
1265
+ }
1266
+ input.addEventListener('blur', commit)
1267
+ input.addEventListener('keydown', (e) => {
1268
+ if (e.key === 'Enter') {
1269
+ e.preventDefault()
1270
+ if (typeof input.blur === 'function') input.blur()
1271
+ else commit()
1272
+ } else if (e.key === 'Escape') {
1273
+ e.preventDefault()
1274
+ input.value = ''
1275
+ input.style.display = 'none'
1276
+ picker.render()
1277
+ }
1278
+ })
1279
+ return picker
1280
+ }
1281
+ const textBasePicker = makeRoleFontPicker('text', 'textBaseTitle', 'textBaseSub', 'dsrb-textbase', 'textBaseTitle')
1282
+ const codeFontPicker = makeRoleFontPicker('code', 'fontRowLabel', 'codeFontSub', 'dsrb-codefont', 'codeFontTitle')
1283
+ const terminalFontPicker = makeRoleFontPicker('terminal', 'fontRowLabel', 'terminalFontSub', 'dsrb-termfont', 'terminalFontTitle')
1284
+
1285
+ /** 字号行:标题 + 当前值 + −/+ 步进器。 */
1286
+ function makeStepperRow(titleText, subText) {
1287
+ const row = el('div', 'dsrb-row')
1288
+ const main = el('div', 'dsrb-rowMain')
1289
+ const title = el('div', 'dsrb-rowTitle')
1290
+ const val = el('b', null, '…')
1291
+ title.append(titleText, val)
1292
+ main.append(title, el('div', 'dsrb-rowSub', subText))
1293
+ const stepper = el('div', 'dsrb-stepper')
1294
+ const minus = el('button', null, '−')
1295
+ const plus = el('button', null, '+')
1296
+ minus.type = plus.type = 'button'
1297
+ minus.setAttribute('aria-label', t('minus'))
1298
+ plus.setAttribute('aria-label', t('plus'))
1299
+ stepper.append(minus, plus)
1300
+ row.append(main, stepper)
1301
+ return { row, val, minus, plus }
1302
+ }
1303
+ const { row: rowFont, val: fontVal, minus: fontMinus, plus: fontPlus } = makeStepperRow(t('fontSize'), t('fontSizeSync'))
1304
+
1305
+ const { row: rowCodeSize, val: codeSizeVal, minus: codeMinus, plus: codePlus } = makeStepperRow(t('sizeRowLabel'), t('codeFontSizeSub'))
1306
+
1307
+ // 行:中英分设(中文、西文两个独立下拉压缩在同一行;中文下拉只接管中日韩
641
1308
  // 字符与中文标点,西文下拉只接管拉丁字母/数字/西文标点,各自独立、互不串扰)
642
1309
  const rowTextFont = el('div', 'dsrb-row')
643
1310
  const textFontMain = el('div', 'dsrb-rowMain')
644
- textFontMain.append(el('div', 'dsrb-rowTitle', '正文字体'), el('div', 'dsrb-rowSub', '仅显示本机已装字体'))
1311
+ textFontMain.append(el('div', 'dsrb-rowTitle', t('splitTitle')), el('div', 'dsrb-rowSub', t('splitSub')))
645
1312
  const textPair = el('div', 'dsrb-pair')
646
1313
  const cjkCol = el('div', 'dsrb-pairCol')
647
- cjkCol.append(el('div', 'dsrb-mini', '中文'))
648
- const textCjkSelect = el('select', 'dsrb-select')
1314
+ cjkCol.append(el('div', 'dsrb-mini', t('cjkLabel')))
1315
+ const textCjkSelect = el('select', 'dsrb-select dsrb-cjk')
1316
+ textCjkSelect.setAttribute('aria-label', t('cjkLabel'))
649
1317
  cjkCol.append(textCjkSelect)
650
1318
  const latinCol = el('div', 'dsrb-pairCol')
651
- latinCol.append(el('div', 'dsrb-mini', '西文'))
652
- const textLatinSelect = el('select', 'dsrb-select')
1319
+ latinCol.append(el('div', 'dsrb-mini', t('latinLabel')))
1320
+ const textLatinSelect = el('select', 'dsrb-select dsrb-latin')
1321
+ textLatinSelect.setAttribute('aria-label', t('latinLabel'))
653
1322
  latinCol.append(textLatinSelect)
654
1323
  textPair.append(cjkCol, latinCol)
655
1324
  textFontMain.append(textPair)
656
1325
  rowTextFont.append(textFontMain)
657
1326
 
658
- // 行:代码字体(消息内代码与代码块)
659
- const rowCodeFont = el('div', 'dsrb-row')
660
- const codeFontMain = el('div', 'dsrb-rowMain')
661
- codeFontMain.append(el('div', 'dsrb-rowTitle', '代码字体'), el('div', 'dsrb-rowSub', '仅显示本机已装字体'))
662
- const codeFontSelect = el('select', 'dsrb-select')
663
- codeFontMain.append(codeFontSelect)
664
- rowCodeFont.append(codeFontMain)
1327
+ const { row: rowTerminalSize, val: termSizeVal, minus: termMinus, plus: termPlus } = makeStepperRow(t('sizeRowLabel'), t('terminalFontSizeSub'))
665
1328
 
666
1329
  // 行:内容宽度
667
1330
  const rowWidth = el('div', 'dsrb-row')
668
1331
  const widthMain = el('div', 'dsrb-rowMain')
669
1332
  const widthTitle = el('div', 'dsrb-rowTitle')
670
1333
  const widthVal = el('b', null, '…')
671
- widthTitle.append('内容宽度 ', widthVal)
672
- widthMain.append(widthTitle, el('div', 'dsrb-rowSub', '与官方手柄同步'))
1334
+ widthTitle.append(t('widthTitle'), widthVal)
1335
+ widthMain.append(widthTitle, el('div', 'dsrb-rowSub', t('widthSub')))
673
1336
  const widthRow = el('div', 'dsrb-widthRow')
674
1337
  const widthSlider = el('input')
675
1338
  widthSlider.type = 'range'
1339
+ widthSlider.setAttribute('aria-label', t('widthTitle').trim())
676
1340
  widthSlider.step = '5'
677
1341
  widthSlider.min = String(CONTENT_MIN)
678
1342
  widthSlider.max = String(CONTENT_MIN)
679
- const widthReset = el('button', null, '自动')
1343
+ const widthReset = el('button', null, t('widthAuto'))
680
1344
  widthReset.type = 'button'
681
1345
  widthRow.append(widthSlider, widthReset)
682
1346
  widthMain.append(widthRow)
683
1347
  rowWidth.append(widthMain)
684
1348
 
685
- // 行:全屏(整条可点;开关状态与真实全屏状态双向同步)
686
- const rowFullscreen = el('div', 'dsrb-row dsrb-row-click')
687
- const fsBox = el('span', 'dsrb-switch')
688
- const fsToggle = el('input')
689
- fsToggle.type = 'checkbox'
690
- fsBox.append(fsToggle, el('span'))
691
- const fsMain = el('div', 'dsrb-rowMain')
692
- fsMain.append(el('div', 'dsrb-rowTitle', '全屏'), el('div', 'dsrb-rowSub', '铺满屏幕 · Esc 退出'))
693
- rowFullscreen.append(fsBox, fsMain)
1349
+ // 行:全屏(开关与真实全屏状态双向同步)
1350
+ const { row: rowFullscreen, input: fsToggle } = makeSwitchRow(t('fullscreenTitle'), t('fullscreenSub'))
694
1351
 
695
1352
  // 三段式结构:拉拽条 / 滚动内容区 / 拉拽条 —— 内容在中间滚动,两条拉拽条
696
1353
  // 永远贴住面板的视觉上/下缘(滚动容器里的绝对定位元素会随内容滚走,不能用绝对定位)
697
1354
  const resizeTop = el('div', 'dsrb-resize-top')
698
1355
  const resizeBottom = el('div', 'dsrb-resize')
699
1356
  const panelBody = el('div', 'dsrb-body')
700
- panelBody.append(headRow, rowFont, rowTextFont, rowCodeFont, rowWidth, rowFullscreen, rowHide, rowTop, rowType)
1357
+ // 三组折叠:组头常显,组体按记住的展开态显示
1358
+ groups.text.body.append(rowFont, textBasePicker.row, rowTextFont)
1359
+ groups.code.body.append(codeFontPicker.row, rowCodeSize)
1360
+ groups.terminal.body.append(terminalFontPicker.row, rowTerminalSize)
1361
+ applyGroupState()
1362
+ panelBody.append(headRow, groups.text.group, groups.code.group, groups.terminal.group, rowWidth, rowFullscreen, rowHide, rowTop, rowType)
701
1363
  panel.append(resizeTop, panelBody, resizeBottom)
702
1364
  panel.style.display = state.open ? '' : 'none'
703
- document.body.appendChild(panel)
1365
+ fab.setAttribute('aria-controls', PANEL_ID)
1366
+ fab.setAttribute('aria-expanded', state.open ? 'true' : 'false')
1367
+ host.appendChild(panel)
704
1368
 
705
1369
  // ── 面板定位(四个方位择一,绝不压住按钮;避开右上角系统按钮条) ──
706
1370
  // 右上角三枚系统按钮是 Electron WCO 原生叠层(绘制在网页层之上、无法覆盖):
@@ -709,7 +1373,8 @@ window.__ModuleLoader__.load({
709
1373
  const titlebarH =
710
1374
  Number.parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--dsh-windows-titlebar-height')) || 40
711
1375
  const CAP_W = 138
712
- function placePanel() {
1376
+ function placePanel(keepScroll) {
1377
+ const scrollTop = typeof keepScroll === 'number' ? keepScroll : panelBody.scrollTop
713
1378
  const r = fab.getBoundingClientRect()
714
1379
  panel.style.height = ''
715
1380
  panel.style.maxHeight = '' // 先按自然高度测量
@@ -771,14 +1436,22 @@ window.__ModuleLoader__.load({
771
1436
  const finalH = Math.max(PANEL_MIN_H, Math.min(ph, vh - best.y - M - BORDER_Y))
772
1437
  panel.style.maxHeight = finalH + 'px'
773
1438
  if (typeof state.panelH === 'number') panel.style.height = finalH + 'px'
1439
+ // 重算高度/位置会让浏览器把超界 scrollTop 归零;按重排前的值恢复
1440
+ const maxScroll = Math.max(0, panelBody.scrollHeight - panelBody.clientHeight)
1441
+ panelBody.scrollTop = Math.min(scrollTop, maxScroll)
774
1442
  }
775
1443
  function togglePanel(open) {
776
1444
  state.open = open
777
1445
  panel.style.display = open ? '' : 'none'
1446
+ if (!open && panel.contains(document.activeElement) && typeof fab.focus === 'function') fab.focus()
1447
+ fab.setAttribute('aria-expanded', open ? 'true' : 'false')
778
1448
  if (open) {
779
- renderWidthRow()
780
- renderFontRow()
1449
+ renderAll()
1450
+ rebuildFontPick()
1451
+ refreshWidthTargets()
781
1452
  placePanel()
1453
+ } else {
1454
+ refreshWidthTargets()
782
1455
  }
783
1456
  writeState({ open })
784
1457
  }
@@ -791,6 +1464,7 @@ window.__ModuleLoader__.load({
791
1464
  // 超出 22:官方设置保持原样,仅用缩放层补齐
792
1465
  applyFont()
793
1466
  renderFontRow()
1467
+ renderGroupSummaries()
794
1468
  writeState({ fontPx: next })
795
1469
  }
796
1470
  function renderFontRow() {
@@ -798,12 +1472,48 @@ window.__ModuleLoader__.load({
798
1472
  fontMinus.disabled = typeof state.fontPx !== 'number' || state.fontPx <= FONT_MIN
799
1473
  fontPlus.disabled = typeof state.fontPx !== 'number' || state.fontPx >= FONT_MAX
800
1474
  }
1475
+ // 官方角色字号行(代码 / 终端):只镜像快照,步进经官方接口写回
1476
+ function renderSizeRow(valEl, minus, plus, n, min, max) {
1477
+ valEl.textContent = typeof n === 'number' ? n + 'px' : '—'
1478
+ minus.disabled = !roleSizeSupported || typeof n !== 'number' || n <= min
1479
+ plus.disabled = !roleSizeSupported || typeof n !== 'number' || n >= max
1480
+ }
1481
+ function renderCodeSizeRow() {
1482
+ renderSizeRow(codeSizeVal, codeMinus, codePlus, fontState.code, CODE_FONT_MIN, CODE_FONT_MAX)
1483
+ }
1484
+ function renderTerminalSizeRow() {
1485
+ renderSizeRow(termSizeVal, termMinus, termPlus, fontState.terminal, TERMINAL_FONT_MIN, TERMINAL_FONT_MAX)
1486
+ }
1487
+ function familyLabel(list, value) {
1488
+ const first = String(value || '').split(',')[0].trim().replace(/^"|"$/g, '')
1489
+ if (!first) return ''
1490
+ const hit = list.find((f) => fontMatches(f, first))
1491
+ return hit ? hit.label : first
1492
+ }
1493
+ function syncFontPicker(picker) {
1494
+ picker.setEnabled(familySupported)
1495
+ if (familySupported) picker.render()
1496
+ }
1497
+ function renderCodeFontRow() { syncFontPicker(codeFontPicker) }
1498
+ function renderTerminalFontRow() { syncFontPicker(terminalFontPicker) }
1499
+ function renderTextBaseRow() { syncFontPicker(textBasePicker) }
1500
+ function renderGroupSummaries() {
1501
+ const textSize = typeof state.fontPx === 'number' ? state.fontPx + 'px' : '—'
1502
+ const split = !!(state.textFontCjk || state.textFontLatin)
1503
+ groups.text.sub.textContent =
1504
+ textSize + ' · ' + (split ? t('groupSummarySplit') : fontState.textFamily ? t('groupSummaryOfficial') : t('defaultText'))
1505
+ const codeSize = typeof fontState.code === 'number' ? fontState.code + 'px' : '—'
1506
+ groups.code.sub.textContent = codeSize + ' · ' + (familyLabel(availCode, fontState.codeFamily) || t('defaultText'))
1507
+ const termSize = typeof fontState.terminal === 'number' ? fontState.terminal + 'px' : '—'
1508
+ groups.terminal.sub.textContent =
1509
+ termSize + ' · ' + (familyLabel(availCode, fontState.terminalFamily) || t('defaultText'))
1510
+ }
801
1511
  function renderWidthRow() {
802
1512
  const info = currentWidthInfo()
803
1513
  if (!info) {
804
1514
  widthSlider.disabled = true
805
1515
  widthReset.disabled = true
806
- widthVal.textContent = '—(打开会话后可用)'
1516
+ widthVal.textContent = t('widthUnavailable')
807
1517
  return
808
1518
  }
809
1519
  widthSlider.min = String(info.min)
@@ -817,11 +1527,18 @@ window.__ModuleLoader__.load({
817
1527
  }
818
1528
  function renderAll() {
819
1529
  hideToggle.checked = !!state.hideComposer
1530
+ hideToggle.setAttribute('aria-checked', hideToggle.checked ? 'true' : 'false')
820
1531
  topToggle.checked = !!state.hideTop
1532
+ topToggle.setAttribute('aria-checked', topToggle.checked ? 'true' : 'false')
821
1533
  textCjkSelect.value = state.textFontCjk ?? ''
822
1534
  textLatinSelect.value = state.textFontLatin ?? ''
823
- codeFontSelect.value = state.codeFont ?? ''
824
1535
  renderFontRow()
1536
+ renderCodeSizeRow()
1537
+ renderTerminalSizeRow()
1538
+ renderCodeFontRow()
1539
+ renderTerminalFontRow()
1540
+ renderTextBaseRow()
1541
+ renderGroupSummaries()
825
1542
  renderTypeRow()
826
1543
  renderWidthRow()
827
1544
  }
@@ -829,6 +1546,7 @@ window.__ModuleLoader__.load({
829
1546
  // ── 功能 1:隐藏输入区(开关 + 整条长条可点) ────────────────────
830
1547
  function applyHideComposer() {
831
1548
  state.hideComposer = hideToggle.checked
1549
+ hideToggle.setAttribute('aria-checked', hideToggle.checked ? 'true' : 'false')
832
1550
  hideTag.disabled = !state.hideComposer
833
1551
  writeState({ hideComposer: state.hideComposer })
834
1552
  }
@@ -838,6 +1556,7 @@ window.__ModuleLoader__.load({
838
1556
  // ── 功能 2:隐藏上边栏(开关 + 整条长条可点) ────────────────────
839
1557
  function applyHideTop() {
840
1558
  state.hideTop = topToggle.checked
1559
+ topToggle.setAttribute('aria-checked', topToggle.checked ? 'true' : 'false')
841
1560
  hideTopTag.disabled = !state.hideTop
842
1561
  writeState({ hideTop: state.hideTop })
843
1562
  }
@@ -853,6 +1572,7 @@ window.__ModuleLoader__.load({
853
1572
  // 其它元素全屏),因此由 fullscreenchange 事件无条件校正开关与补偿标记
854
1573
  const on = inPageFullscreen()
855
1574
  fsToggle.checked = on
1575
+ fsToggle.setAttribute('aria-checked', on ? 'true' : 'false')
856
1576
  const root = document.documentElement
857
1577
  if (on) root.setAttribute(FS_ATTR, '')
858
1578
  else root.removeAttribute(FS_ATTR)
@@ -886,53 +1606,47 @@ window.__ModuleLoader__.load({
886
1606
  fontPlus.addEventListener('click', () => {
887
1607
  setFontPx((typeof state.fontPx === 'number' ? state.fontPx : fontState.official ?? 16) + 1)
888
1608
  })
889
-
890
- // ── 功能 5:字体选择(正文·中文 / 正文·西文 / 代码) ───────────
891
- textCjkSelect.addEventListener('change', () => {
892
- const id = textCjkSelect.value || null
893
- state.textFontCjk = fontChoice(availCjk, id) ? id : null
894
- textCjkSelect.value = state.textFontCjk ?? ''
895
- writeState({ textFontCjk: state.textFontCjk })
896
- fontPickBuilt = false
897
- rebuildFontPick()
1609
+ // 代码字号:官方角色字号(10–16)的入口;写回后由 theme/change 快照刷新显示
1610
+ codeMinus.addEventListener('click', () => {
1611
+ if (typeof fontState.code !== 'number') return
1612
+ officialSetFontSize('code', Math.max(CODE_FONT_MIN, fontState.code - 1))
898
1613
  })
899
- textLatinSelect.addEventListener('change', () => {
900
- const id = textLatinSelect.value || null
901
- state.textFontLatin = fontChoice(availLatin, id) ? id : null
902
- textLatinSelect.value = state.textFontLatin ?? ''
903
- writeState({ textFontLatin: state.textFontLatin })
904
- fontPickBuilt = false
905
- rebuildFontPick()
1614
+ codePlus.addEventListener('click', () => {
1615
+ if (typeof fontState.code !== 'number') return
1616
+ officialSetFontSize('code', Math.min(CODE_FONT_MAX, fontState.code + 1))
906
1617
  })
907
- codeFontSelect.addEventListener('change', () => {
908
- const id = codeFontSelect.value || null
909
- state.codeFont = fontChoice(availCode, id) ? id : null
910
- codeFontSelect.value = state.codeFont ?? ''
911
- writeState({ codeFont: state.codeFont })
912
- fontPickBuilt = false
913
- rebuildFontPick()
1618
+
1619
+ // ── 功能 5:字体选择(中英分设 / 官方基础 / 代码 / 终端) ──────
1620
+ textCjkSelect.addEventListener('change', () => commitTextFont('cjk', textCjkSelect.value))
1621
+ textLatinSelect.addEventListener('change', () => commitTextFont('latin', textLatinSelect.value))
1622
+ // 终端字号(官方角色字号)
1623
+ termMinus.addEventListener('click', () => {
1624
+ if (typeof fontState.terminal !== 'number') return
1625
+ officialSetFontSize('terminal', Math.max(TERMINAL_FONT_MIN, fontState.terminal - 1))
1626
+ })
1627
+ termPlus.addEventListener('click', () => {
1628
+ if (typeof fontState.terminal !== 'number') return
1629
+ officialSetFontSize('terminal', Math.min(TERMINAL_FONT_MAX, fontState.terminal + 1))
914
1630
  })
915
1631
 
916
1632
  // ── 功能 7:行距 / 字距(0.1 步进;到达边界即回"默认",覆盖层随之停用) ──
917
1633
  function setLineHeight(v) {
918
1634
  const next = Math.min(LINEH_MAX, Math.max(LINEH_MIN, Math.round(v * 10) / 10))
919
1635
  state.lineHeight = next > LINEH_MIN ? next : null
920
- fontPickBuilt = false
921
- rebuildFontPick()
1636
+ kickFontPick()
922
1637
  renderTypeRow()
923
1638
  writeState({ lineHeight: state.lineHeight })
924
1639
  }
925
1640
  function setLetterSpacing(v) {
926
1641
  const next = Math.min(LETTERSP_MAX, Math.max(LETTERSP_MIN, Math.round(v * 10) / 10))
927
1642
  state.letterSpacing = next !== 0 ? next : null
928
- fontPickBuilt = false
929
- rebuildFontPick()
1643
+ kickFontPick()
930
1644
  renderTypeRow()
931
1645
  writeState({ letterSpacing: state.letterSpacing })
932
1646
  }
933
1647
  function renderTypeRow() {
934
- lhVal.textContent = state.lineHeight ? state.lineHeight.toFixed(1) + '×' : '默认'
935
- lsVal.textContent = state.letterSpacing ? state.letterSpacing.toFixed(1) + 'px' : '默认'
1648
+ lhVal.textContent = state.lineHeight ? state.lineHeight.toFixed(1) + '×' : t('defaultText')
1649
+ lsVal.textContent = state.letterSpacing ? state.letterSpacing.toFixed(1) + 'px' : t('defaultText')
936
1650
  }
937
1651
  lhMinus.addEventListener('click', () => setLineHeight((state.lineHeight ?? LINEH_MIN) - 0.1))
938
1652
  lhPlus.addEventListener('click', () => setLineHeight((state.lineHeight ?? LINEH_MIN) + 0.1))
@@ -945,16 +1659,67 @@ window.__ModuleLoader__.load({
945
1659
  let officialDragMoved = false
946
1660
  let officialDragStart = null
947
1661
  let officialDragAdopt = false
1662
+ let widthStartupAdopted = false
1663
+ /** 首次拿到有效列宽时,范围宽度以官方键为准:插件停用期间官方改过的宽度
1664
+ * 不会被旧状态覆盖;扩展宽度(> 官方上限)由插件自己持有,不参与采纳。 */
1665
+ function adoptOfficialWidthOnStart() {
1666
+ if (widthStartupAdopted) return
1667
+ const t = widthTargets()
1668
+ if (!t || !(t.column > 0)) return
1669
+ widthStartupAdopted = true
1670
+ if (state.widthPx == null) {
1671
+ markWidthExt(false)
1672
+ return
1673
+ }
1674
+ const omax = officialMaxFor(t.column)
1675
+ const pref = readWidthPreference()
1676
+ if (state.widthExt === true) return // 插件扩展:官方 peg 值不参与
1677
+ if (state.widthExt === false) {
1678
+ // 已知不是扩展:跟随官方。官方键超当前上限按上限;键缺失则收到上限(不再越限)
1679
+ markWidthExt(false)
1680
+ const official = pref == null ? Math.min(state.widthPx, omax) : Math.min(pref, omax)
1681
+ if (official !== state.widthPx) {
1682
+ state.widthPx = Math.min(Math.max(Math.round(official), CONTENT_MIN), extCapFor(t.column))
1683
+ writeState({ widthPx: state.widthPx })
1684
+ }
1685
+ return
1686
+ }
1687
+ // 旧版状态(无 widthExt):用官方键与插件值的关系推断
1688
+ if (pref == null) {
1689
+ if (state.widthPx > omax) markWidthExt(true)
1690
+ else markWidthExt(false)
1691
+ return
1692
+ }
1693
+ if (pref === state.widthPx && state.widthPx > omax) {
1694
+ // 同一官方镜像因窗口变窄而超出上限:按上限收敛,不能误当扩展覆盖
1695
+ markWidthExt(false)
1696
+ state.widthPx = Math.min(Math.max(Math.round(omax), CONTENT_MIN), extCapFor(t.column))
1697
+ writeState({ widthPx: state.widthPx })
1698
+ return
1699
+ }
1700
+ if (pref < state.widthPx) {
1701
+ // 官方键更低:旧版扩展通常被 peg 在旧上限;保留插件宽度,避免丢扩展
1702
+ markWidthExt(state.widthPx > omax)
1703
+ return
1704
+ }
1705
+ // 官方调大或一致:采纳官方值(超上限按上限)
1706
+ const official = Math.min(pref, omax)
1707
+ markWidthExt(false)
1708
+ if (official === state.widthPx) return
1709
+ state.widthPx = Math.min(Math.max(Math.round(official), CONTENT_MIN), extCapFor(t.column))
1710
+ writeState({ widthPx: state.widthPx })
1711
+ }
948
1712
  function setWidthPx(v) {
949
1713
  if (v === null) {
950
1714
  state.widthPx = null
951
1715
  } else {
952
1716
  const t = widthTargets()
953
- if (!t) return // 无会话容器(滑块与"自动"此时均处于禁用态)
1717
+ if (!t || !(t.column > 0)) return // 无可用会话列宽(滑块与"自动"此时均处于禁用态)
954
1718
  state.widthPx = Math.min(Math.max(Math.round(v), CONTENT_MIN), extCapFor(t.column))
955
1719
  }
956
- applyWidthState()
1720
+ applyWidthState(v === null)
957
1721
  writeState({ widthPx: state.widthPx })
1722
+ refreshWidthTargets()
958
1723
  renderWidthRow()
959
1724
  }
960
1725
  widthSlider.addEventListener('pointerdown', () => {
@@ -967,6 +1732,7 @@ window.__ModuleLoader__.load({
967
1732
  }
968
1733
  }
969
1734
  window.addEventListener('pointerup', onWindowPointerUp)
1735
+ window.addEventListener('pointercancel', onWindowPointerUp)
970
1736
  widthSlider.addEventListener('input', () => {
971
1737
  setWidthPx(Number(widthSlider.value))
972
1738
  })
@@ -979,11 +1745,13 @@ window.__ModuleLoader__.load({
979
1745
  const target = e.target
980
1746
  const handle = target && target.closest ? target.closest('[data-width-handle]') : null
981
1747
  if (handle) {
1748
+ if (state.open) attachWidthObserver()
1749
+ attachColumnObserver()
982
1750
  officialDragging = true
983
1751
  officialDragMoved = false
984
1752
  officialDragStart = { x: e.clientX, y: e.clientY }
985
1753
  const t = widthTargets()
986
- officialDragAdopt = !!(t && state.widthPx != null && state.widthPx > officialMaxFor(t.column))
1754
+ officialDragAdopt = !!(t && t.column > 0 && state.widthPx != null && state.widthPx > officialMaxFor(t.column))
987
1755
  }
988
1756
  if (state.open && !panel.contains(target) && !fab.contains(target)) {
989
1757
  togglePanel(false)
@@ -1002,29 +1770,38 @@ window.__ModuleLoader__.load({
1002
1770
  }
1003
1771
  function onDocPointerUp() {
1004
1772
  if (!officialDragging) return
1005
- const adopt = officialDragAdopt && officialDragMoved
1773
+ const moved = officialDragMoved
1006
1774
  officialDragMoved = false
1007
1775
  officialDragStart = null
1008
1776
  officialDragAdopt = false
1009
- if (!adopt) {
1777
+ if (!moved) {
1010
1778
  officialDragging = false
1779
+ refreshWidthTargets()
1011
1780
  return
1012
1781
  }
1013
- // 采纳期间保持"官方拖动中"状态:定点漂移修复不会抢在官方提交之前插手
1782
+ // 任何一次官方手柄拖动松手后都采纳官方最终值(含范围内拖动);
1783
+ // 等待 0ms 让官方先提交,期间保持"官方拖动中"避免漂移修复抢跑。
1014
1784
  setTimeout(() => {
1785
+ if (disposed) return
1015
1786
  officialDragging = false
1016
1787
  const pref = readWidthPreference()
1017
1788
  state.widthPx = pref != null ? pref : null
1018
1789
  applyWidthState()
1019
1790
  writeState({ widthPx: state.widthPx })
1020
- renderWidthRow()
1791
+ refreshWidthTargets()
1021
1792
  }, 0)
1022
1793
  }
1023
1794
  function onDocPointerCancel() {
1795
+ const restore = officialDragAdopt && state.widthPx != null
1024
1796
  officialDragging = false
1025
1797
  officialDragMoved = false
1026
1798
  officialDragStart = null
1027
1799
  officialDragAdopt = false
1800
+ if (restore) {
1801
+ // 拖动被系统取消(未松手):把交还官方时移除的扩展覆盖装回去
1802
+ applyWidthState()
1803
+ }
1804
+ refreshWidthTargets() // 释放拖动期临时挂上的观察器,并按当前宽度归位
1028
1805
  }
1029
1806
  document.addEventListener('pointerdown', onDocPointerDown, true)
1030
1807
  document.addEventListener('pointermove', onDocPointerMove, true)
@@ -1034,7 +1811,7 @@ window.__ModuleLoader__.load({
1034
1811
  // 官方手柄拖动 / 窗口尺寸变化 → 目标元素 style 变化 → 反向同步面板
1035
1812
  let watchedTarget = null
1036
1813
  const widthObserver = new MutationObserver(() => {
1037
- if (!widthDragging) renderWidthRow()
1814
+ if (!disposed && state.open && !widthDragging) renderWidthRow()
1038
1815
  })
1039
1816
  function attachWidthObserver() {
1040
1817
  const t = widthTargets()
@@ -1050,7 +1827,11 @@ window.__ModuleLoader__.load({
1050
1827
  // 官方键/官方变量的收敛压到同一批回调内,开合过程不再出现滞留窄值的中间态。
1051
1828
  let watchedColumn = null
1052
1829
  const columnObserver = new ResizeObserver(() => {
1830
+ if (disposed) return
1831
+ adoptOfficialWidthOnStart()
1053
1832
  normalizeWidthDrift()
1833
+ if (state.widthPx != null && !widthTargets()) scheduleWidthTargetRetry()
1834
+ else widthTargetRetryCount = 0
1054
1835
  if (state.open && !officialDragging && !widthDragging) renderWidthRow()
1055
1836
  })
1056
1837
  function attachColumnObserver() {
@@ -1062,84 +1843,58 @@ window.__ModuleLoader__.load({
1062
1843
  if (container) columnObserver.observe(container)
1063
1844
  }
1064
1845
 
1065
- // ── 功能 3 的服务对接:订阅官方主题(字号基准与官方直连) ────────
1066
- ctx.inject(['theme'], (tctx) => {
1067
- fontSvc = tctx.theme
1068
- let firstSync = true
1069
- const sync = (snap) => {
1070
- const n = snap && snap.fontSize
1071
- if (typeof n === 'number' && n > 0) fontState.official = n
1072
- if (!fontSelfWrite) {
1073
- if (
1074
- firstSync &&
1075
- typeof state.fontPx === 'number' &&
1076
- state.fontPx <= FONT_OFFICIAL_MAX &&
1077
- state.fontPx !== fontState.official
1078
- ) {
1079
- // 启动恢复:面板上次设定的字号(范围内)直写回官方设置,
1080
- // 保证"范围内 ⇔ 官方值 === 面板值"的稳定关系
1081
- firstSync = false
1082
- applyFont()
1083
- writeOfficialFont(state.fontPx) // 官方更新后会再次进入本函数(自写标记路径)
1084
- return
1085
- }
1086
- firstSync = false
1087
- if (typeof state.fontPx !== 'number') state.fontPx = fontState.official
1088
- else if (state.fontPx <= FONT_OFFICIAL_MAX) state.fontPx = fontState.official // 范围内以官方为准
1089
- // 超出 22 的绝对目标保持不动,倍率自动适配
1090
- writeState({ fontPx: state.fontPx })
1091
- renderFontRow()
1092
- }
1093
- applyFont()
1846
+ /** 只在明确事件(启动/会话切换/面板打开/窗口变化/官方手柄)后复核宽度。 */
1847
+ function refreshWidthTargets() {
1848
+ adoptOfficialWidthOnStart()
1849
+ // 只有面板打开时才需要监听官方手柄写变量(用于滑杆实时回显);
1850
+ // 列观察仅在扩展宽度生效或面板打开时需要;其余时候不挂任何后台观察器。
1851
+ if (state.open) attachWidthObserver()
1852
+ else {
1853
+ widthObserver.disconnect()
1854
+ watchedTarget = null
1094
1855
  }
1095
- // 先订阅、再首读:启动恢复的 setFontSize 会同步发布 theme/change,
1096
- // 若先读后订会错过这次回声(官方值会暂时读旧)
1097
- tctx.on('theme/change', (snap) => sync(snap))
1098
- try {
1099
- sync(tctx.theme.getTheme())
1100
- } catch {
1101
- applyFont() // 服务首读异常时至少按兜底基准绘制一次
1856
+ if (state.open || state.widthPx != null) attachColumnObserver()
1857
+ else {
1858
+ columnObserver.disconnect()
1859
+ watchedColumn = null
1102
1860
  }
1103
- })
1104
-
1105
- // ── 启动时应用已保存的功能状态 ──────────────────────────────────
1106
- if (typeof state.fontPx === 'number') {
1107
- state.fontPx = Math.min(FONT_MAX, Math.max(FONT_MIN, Math.round(state.fontPx)))
1108
- } else {
1109
- state.fontPx = null
1861
+ normalizeWidthDrift()
1862
+ // 会话容器可能晚于插件挂载:有保存宽度但暂时没有目标时,做有限次重试
1863
+ if (state.widthPx != null && !widthTargets()) scheduleWidthTargetRetry()
1864
+ else widthTargetRetryCount = 0
1865
+ if (state.open && !officialDragging && !widthDragging) renderWidthRow()
1866
+ }
1867
+ let widthRefreshTimer = null
1868
+ let widthTargetRetry = null
1869
+ let widthTargetRetryCount = 0
1870
+ function scheduleWidthTargetRetry() {
1871
+ if (disposed || widthTargetRetry || widthTargetRetryCount >= 3) return
1872
+ widthTargetRetry = setTimeout(() => {
1873
+ widthTargetRetry = null
1874
+ widthTargetRetryCount += 1
1875
+ if (!disposed) refreshWidthTargets()
1876
+ }, 250)
1877
+ }
1878
+ function scheduleWidthRefresh() {
1879
+ // 自动宽度且面板关闭时,会话切换没有宽度工作要做,直接忽略
1880
+ if (!state.open && state.widthPx == null) return
1881
+ if (disposed || widthRefreshTimer) return
1882
+ widthRefreshTimer = setTimeout(() => {
1883
+ widthRefreshTimer = null
1884
+ if (!disposed) refreshWidthTargets()
1885
+ }, 0)
1110
1886
  }
1111
- if (typeof state.widthPx !== 'number' || !(state.widthPx > 0)) state.widthPx = null
1112
- if (typeof state.lineHeight === 'number') {
1113
- const lh = Math.min(LINEH_MAX, Math.max(LINEH_MIN, Math.round(state.lineHeight * 10) / 10))
1114
- state.lineHeight = lh > LINEH_MIN ? lh : null
1115
- } else state.lineHeight = null
1116
- if (typeof state.letterSpacing === 'number') {
1117
- const ls = Math.min(LETTERSP_MAX, Math.max(LETTERSP_MIN, Math.round(state.letterSpacing * 10) / 10))
1118
- state.letterSpacing = ls !== 0 ? ls : null
1119
- } else state.letterSpacing = null
1120
- state.panelX = typeof state.panelX === 'number' ? state.panelX : null
1121
- state.panelY = typeof state.panelY === 'number' ? state.panelY : null
1122
- state.panelH = typeof state.panelH === 'number' && state.panelH > 0 ? state.panelH : null
1123
1887
 
1124
1888
  // ── 字体选择:本机可用性过滤 + 选择恢复 + 覆盖层生成 ────────────
1125
- function fillFontSelect(select, list) {
1126
- select.textContent = ''
1127
- const def = el('option', null, '默认(官方)')
1128
- def.value = ''
1129
- select.append(def)
1130
- for (const f of list) {
1131
- const opt = el('option', null, f.label)
1132
- opt.value = f.id
1133
- select.append(opt)
1134
- }
1135
- }
1136
- const availCjk = TEXT_FONTS_CJK.filter((f) => fontAvailable(f.name)) // 只保留本机已装
1137
- const availLatin = TEXT_FONTS_LATIN.filter((f) => fontAvailable(f.name))
1138
- const availCode = CODE_FONTS.filter((f) => fontAvailable(f.name))
1139
- fillFontSelect(textCjkSelect, availCjk)
1140
- fillFontSelect(textLatinSelect, availLatin)
1141
- fillFontSelect(codeFontSelect, availCode)
1142
- // 已保存的选择若已不在本机(被卸载 / 换机器)→ 回默认
1889
+ const availCjk = installedFonts('cjk')
1890
+ const availLatin = installedFonts('latin')
1891
+ const availCode = installedFonts('code')
1892
+ fillFontOptions(textCjkSelect, availCjk)
1893
+ fillFontOptions(textLatinSelect, availLatin)
1894
+ textBasePicker.fill(installedFonts('text'))
1895
+ codeFontPicker.fill(availCode)
1896
+ terminalFontPicker.fill(availCode)
1897
+ // 已保存的正文字体选择若已不在本机(被卸载 / 换机器)→ 回默认
1143
1898
  let fontsReset = false
1144
1899
  if (state.textFontCjk && !fontChoice(availCjk, state.textFontCjk)) {
1145
1900
  state.textFontCjk = null
@@ -1149,29 +1904,61 @@ window.__ModuleLoader__.load({
1149
1904
  state.textFontLatin = null
1150
1905
  fontsReset = true
1151
1906
  }
1152
- if (state.codeFont && !fontChoice(availCode, state.codeFont)) {
1153
- state.codeFont = null
1154
- fontsReset = true
1155
- }
1156
- if (fontsReset) writeState({ textFontCjk: state.textFontCjk, textFontLatin: state.textFontLatin, codeFont: state.codeFont })
1907
+ if (fontsReset) writeState({ textFontCjk: state.textFontCjk, textFontLatin: state.textFontLatin })
1157
1908
  let fontPickBuilt = false
1158
- /** 生成 / 刷新字体与排版覆盖层;官方主题未就绪时保持停用,等定时器重试。 */
1909
+ let fontRetryTimer = null
1910
+ let fontRetryStep = 0
1911
+ const FONT_RETRY_DELAYS = [250, 750, 1500, 3000, 5000]
1912
+ function hasFontFeatures() {
1913
+ return !!(state.textFontCjk || state.textFontLatin || state.lineHeight != null || state.letterSpacing != null)
1914
+ }
1915
+ function cancelFontRetry() {
1916
+ if (fontRetryTimer) {
1917
+ clearTimeout(fontRetryTimer)
1918
+ fontRetryTimer = null
1919
+ }
1920
+ }
1921
+ /** 官方主题样式可能晚于插件就绪:有限次退避重试,不常驻轮询。 */
1922
+ function scheduleFontRetry() {
1923
+ if (disposed || fontPickBuilt || fontRetryTimer || !hasFontFeatures()) return
1924
+ if (fontRetryStep >= FONT_RETRY_DELAYS.length) return
1925
+ const delay = FONT_RETRY_DELAYS[fontRetryStep++]
1926
+ fontRetryTimer = setTimeout(() => {
1927
+ fontRetryTimer = null
1928
+ if (!disposed) rebuildFontPick()
1929
+ }, delay)
1930
+ }
1931
+ /** 生成 / 刷新字体与排版覆盖层;主题未就绪时保持停用并安排有限重试。 */
1159
1932
  function rebuildFontPick() {
1160
- if (!state.textFontCjk && !state.textFontLatin && !state.codeFont && state.lineHeight == null && state.letterSpacing == null) {
1933
+ if (!hasFontFeatures()) {
1934
+ cancelFontRetry()
1161
1935
  fontPickTag.disabled = true
1162
1936
  fontPickBuilt = false
1163
1937
  return
1164
1938
  }
1165
1939
  if (fontPickBuilt) return
1166
1940
  const css = buildFontPickCss()
1167
- if (!css) return
1941
+ if (!css) {
1942
+ scheduleFontRetry()
1943
+ return
1944
+ }
1945
+ cancelFontRetry()
1946
+ fontRetryStep = 0
1168
1947
  fontPickTag.textContent = css
1169
1948
  fontPickTag.disabled = false
1170
1949
  fontPickBuilt = true
1171
1950
  }
1951
+ /** 用户主动改字体/行距:重开一轮有限重试。 */
1952
+ function kickFontPick() {
1953
+ fontRetryStep = 0
1954
+ cancelFontRetry()
1955
+ fontPickBuilt = false
1956
+ rebuildFontPick()
1957
+ }
1172
1958
 
1173
1959
  applyFont()
1174
1960
  renderAll()
1961
+ adoptOfficialWidthOnStart()
1175
1962
  applyWidthState()
1176
1963
  rebuildFontPick()
1177
1964
  // 面板开着启动(上次退出未关):补挂载定位——不经 togglePanel 时面板没有
@@ -1214,6 +2001,11 @@ window.__ModuleLoader__.load({
1214
2001
  fab.addEventListener('pointermove', onPointerMove)
1215
2002
  fab.addEventListener('pointerup', onPointerUp)
1216
2003
  fab.addEventListener('pointercancel', onPointerCancel)
2004
+ // 键盘激活(Enter/Space)产生的 click 没有指针坐标,单独走开合;
2005
+ // 鼠标 click 的 detail>0,由 pointerup 路径处理,避免双触发。
2006
+ fab.addEventListener('click', (e) => {
2007
+ if (e.detail === 0) togglePanel(!state.open)
2008
+ })
1217
2009
  closeBtn.addEventListener('click', () => togglePanel(false))
1218
2010
 
1219
2011
  // ── 功能 8:面板拖动 / 拉拽高度 ──────────────────────────────────
@@ -1322,7 +2114,7 @@ window.__ModuleLoader__.load({
1322
2114
  window.addEventListener('pointermove', onPanelPointerMove)
1323
2115
  window.addEventListener('pointerup', onPanelPointerUp)
1324
2116
  window.addEventListener('pointercancel', onPanelPointerUp)
1325
- headRow.title = '面板任意空白处可拖动 · 双击标题栏复位'
2117
+ headRow.title = t('dragHint')
1326
2118
  headRow.addEventListener('dblclick', () => {
1327
2119
  // 复位:回到自动布局(位置 + 高度一起,回到"高度锁"状态)
1328
2120
  state.panelX = null
@@ -1369,16 +2161,46 @@ window.__ModuleLoader__.load({
1369
2161
  })
1370
2162
  return turndownSvc
1371
2163
  }
2164
+ /** 压缩段间空行,但围栏代码块内部原样保留(代码里的连续空行不能被折掉)。 */
2165
+ function tidyMarkdown(md) {
2166
+ const out = []
2167
+ let fence = null
2168
+ let blanks = 0
2169
+ for (const line of md.split('\n')) {
2170
+ const m = /^\s*(?:>\s*)*(`{3,}|~{3,})/.exec(line)
2171
+ if (fence) {
2172
+ out.push(line)
2173
+ if (m && m[1][0] === fence[0] && m[1].length >= fence.length &&
2174
+ /^\s*(?:>\s*)*(?:`{3,}|~{3,})\s*$/.test(line)) fence = null
2175
+ continue
2176
+ }
2177
+ if (!line.trim()) {
2178
+ if (blanks < 1) out.push('')
2179
+ blanks += 1
2180
+ continue
2181
+ }
2182
+ blanks = 0
2183
+ out.push(line)
2184
+ if (m) fence = m[1]
2185
+ }
2186
+ return out.join('\n').trim()
2187
+ }
1372
2188
  function onCopy(e) {
1373
2189
  try {
1374
2190
  const sel = document.getSelection()
1375
2191
  if (!sel || sel.isCollapsed || !sel.rangeCount) return
1376
2192
  // 选区两端都要在消息行内才接管(跨区选择保持系统默认行为)
1377
2193
  const inChat = (n) => {
1378
- const el = n && (n.nodeType === 3 ? n.parentElement : n)
1379
- return !!(el && el.closest && el.closest('[data-chat-node-key]'))
2194
+ const node = n && (n.nodeType === 3 ? n.parentElement : n)
2195
+ return !!(node && node.closest && node.closest('[data-chat-node-key]'))
1380
2196
  }
1381
2197
  if (!inChat(sel.anchorNode) || !inChat(sel.focusNode)) return
2198
+ // 可编辑区域(输入框/富文本编辑器)保持原生复制
2199
+ const inEditable = (n) => {
2200
+ const node = n && (n.nodeType === 3 ? n.parentElement : n)
2201
+ return !!(node && node.closest && node.closest('input, textarea, [contenteditable="true"], [contenteditable=""], [contenteditable="plaintext-only"]'))
2202
+ }
2203
+ if (inEditable(sel.anchorNode) || inEditable(sel.focusNode)) return
1382
2204
  const elOf = (n) => (n && n.nodeType === 3 ? n.parentElement : n)
1383
2205
  const ka = elOf(sel.anchorNode).closest('.katex')
1384
2206
  const kf = elOf(sel.focusNode).closest('.katex')
@@ -1393,10 +2215,17 @@ window.__ModuleLoader__.load({
1393
2215
  }
1394
2216
  }
1395
2217
  // 与 KaTeX 官方 copy-tex 同策略:端点落在公式内时先把工作范围扩到整个公式,
1396
- // "只碰一角"也能拿到完整 LaTeX(只影响复制内容,不动真实选区)
2218
+ // "只碰一角"也能拿到完整 LaTeX。必须按 Range 的实际起止端判断,
2219
+ // 不能按 anchor/focus —— 从右向左拖选时方向相反,按方向扩会截掉选区左侧。
1397
2220
  const work = sel.getRangeAt(0).cloneRange()
1398
- if (ka) work.setStartBefore(ka)
1399
- if (kf) work.setEndAfter(kf)
2221
+ const katexAt = (n) => {
2222
+ const node = elOf(n)
2223
+ return node && node.closest ? node.closest('.katex') : null
2224
+ }
2225
+ const ks = katexAt(work.startContainer)
2226
+ const ke = katexAt(work.endContainer)
2227
+ if (ks) work.setStartBefore(ks)
2228
+ if (ke) work.setEndAfter(ke)
1400
2229
  const box = document.createElement('div')
1401
2230
  // 选区克隆丢掉共同祖先往上的层级(代码块外层的 <pre> 即如此丢的),转换器的
1402
2231
  // 空白折叠随即把换行折掉;按现场回收"预格式化"祖先链(判据=实际生效的
@@ -1409,10 +2238,16 @@ window.__ModuleLoader__.load({
1409
2238
  anc = anc.parentElement
1410
2239
  }
1411
2240
  let topShell = -1
2241
+ let inlineShell = -1
2242
+ const INLINE_SHELL = new Set(['CODE', 'STRONG', 'B', 'EM', 'I', 'DEL', 'S', 'STRIKE', 'A'])
1412
2243
  for (let i = 0; i < ancShells.length; i++) {
1413
2244
  const ws = getComputedStyle(ancShells[i]).whiteSpace
1414
2245
  if (ws === 'pre' || ws === 'pre-wrap' || ws === 'pre-line' || ws === 'break-spaces') topShell = i
2246
+ // 行内语义元素同样会在克隆时丢外壳:<code> 补 `…`,粗体/斜体/
2247
+ // 删除线/链接补对应 Markdown;取最外层,嵌套格式才能完整保留。
2248
+ if (INLINE_SHELL.has(ancShells[i].tagName)) inlineShell = i
1415
2249
  }
2250
+ if (topShell < 0 && inlineShell >= 0) topShell = inlineShell
1416
2251
  let nestAt = box
1417
2252
  for (let i = topShell; i >= 0; i--) {
1418
2253
  const shell = ancShells[i].cloneNode(false)
@@ -1422,8 +2257,7 @@ window.__ModuleLoader__.load({
1422
2257
  nestAt.append(work.cloneContents())
1423
2258
  // 公式场景先留存原始 HTML(含 <math>):粘进 Word 等富目标仍可显示公式
1424
2259
  const html = box.querySelector('.katex') ? box.innerHTML : null
1425
- // 标签→Markdown 映射由标准库 turndown 承担(公式已由上面的 katex 规则还原为 LaTeX)
1426
- const md = getTurndown().turndown(box).replace(/\n{3,}/g, '\n\n').trim()
2260
+ const md = tidyMarkdown(getTurndown().turndown(box))
1427
2261
  if (!md) return
1428
2262
  e.clipboardData.setData('text/plain', md)
1429
2263
  if (html) e.clipboardData.setData('text/html', html)
@@ -1434,43 +2268,54 @@ window.__ModuleLoader__.load({
1434
2268
  }
1435
2269
  }
1436
2270
  document.addEventListener('copy', onCopy, true)
2271
+ /** 文本编辑类控件里的 Esc 先交给控件(取消输入/收起下拉),不关面板。 */
2272
+ function isTextEditingTarget(n) {
2273
+ if (!n || !n.closest) return false
2274
+ if (n.closest('textarea, [contenteditable="true"], [contenteditable=""], [contenteditable="plaintext-only"]')) return true
2275
+ return n.tagName === 'INPUT' && /^(text|search|email|url|tel|password|number)$/i.test(String(n.type || ''))
2276
+ }
2277
+ function onDocKeyDown(e) {
2278
+ if (e.key !== 'Escape' || !state.open || e.defaultPrevented) return
2279
+ if (document.fullscreenElement) return // 全屏下 Esc 先交给浏览器退出全屏
2280
+ // 只保护插件面板内的文本编辑控件;其它区域照常 Esc 收起面板
2281
+ if (e.target && e.target.closest && e.target.closest('.dsrb-panel') && isTextEditingTarget(e.target)) return
2282
+ // 官方对话框 / 菜单里按 Esc:让官方先处理,不抢关面板
2283
+ const inOverlay = (n) => !!(n && n.closest && n.closest('[role="dialog"], [role="menu"], [role="listbox"]'))
2284
+ if (inOverlay(e.target) || inOverlay(document.activeElement)) return
2285
+ togglePanel(false) // 不 preventDefault:不吞官方自己的 Esc 行为
2286
+ }
2287
+ document.addEventListener('keydown', onDocKeyDown)
1437
2288
 
1438
2289
  function onResize() {
1439
2290
  const [x, y] = clampPos(fab.offsetLeft, fab.offsetTop, FAB_SIZE, FAB_SIZE)
1440
2291
  fab.style.left = x + 'px'
1441
2292
  fab.style.top = y + 'px'
1442
- normalizeWidthDrift()
1443
- if (state.open) {
1444
- renderWidthRow()
1445
- placePanel()
1446
- }
2293
+ refreshWidthTargets()
2294
+ if (state.open) placePanel()
1447
2295
  }
1448
2296
  window.addEventListener('resize', onResize)
1449
2297
 
1450
- // 会话 DOM 迟现/被替换 → 轻量定时器持续核对(同节点零开销),顺带漂移兜底与
1451
- // 面板宽度行刷新;列宽突变由 columnObserver 即时处理,定时器只兜底重挂。
1452
- attachWidthObserver()
1453
- attachColumnObserver()
1454
- const retryTimer = setInterval(() => {
1455
- attachWidthObserver()
1456
- attachColumnObserver()
1457
- normalizeWidthDrift()
1458
- rebuildFontPick() // 官方主题样式稍后就绪时补建覆盖层(已建则零开销早退)
1459
- if (state.open && !officialDragging && !widthDragging) renderWidthRow()
1460
- }, 2000)
2298
+ // 宽度目标只随事件变化:启动、面板打开、窗口尺寸、会话切换、官方手柄操作。
2299
+ refreshWidthTargets()
1461
2300
 
1462
2301
  // 启动标记:记录加载时刻与版本(供诊断确认插件已加载;不影响功能)
1463
2302
  writeState({ lastBootAt: Date.now(), lastBootVersion: VERSION })
1464
- console.log('[dsh-happy-reader] 客户端半边已加载 v' + VERSION)
1465
2303
 
1466
- // ── 卸载清理(官方 ctx.effect 机制自动执行;同一函数登记到 window.__dsrbDispose
1467
- // 供热重载重建前先拆旧实例;幂等,可重复调用) ──
2304
+ // ── 卸载清理(React 槽位卸载 / 插件热重载 / ctx.effect 三路都会触发;
2305
+ // 幂等,可重复调用) ──
1468
2306
  const dispose = () => {
1469
- clearInterval(retryTimer)
2307
+ if (disposed) return // 幂等:热重载/槽位卸载可能重复调用,不能重复抹掉新实例的全局变量
2308
+ disposed = true
2309
+ overlayAlive = false
2310
+ ui = null
2311
+ if (widthRefreshTimer) { clearTimeout(widthRefreshTimer); widthRefreshTimer = null }
2312
+ if (widthTargetRetry) { clearTimeout(widthTargetRetry); widthTargetRetry = null }
2313
+ cancelFontRetry()
1470
2314
  widthObserver.disconnect()
1471
2315
  columnObserver.disconnect()
1472
2316
  window.removeEventListener('resize', onResize)
1473
2317
  window.removeEventListener('pointerup', onWindowPointerUp)
2318
+ window.removeEventListener('pointercancel', onWindowPointerUp)
1474
2319
  window.removeEventListener('pointermove', onPanelPointerMove)
1475
2320
  window.removeEventListener('pointerup', onPanelPointerUp)
1476
2321
  window.removeEventListener('pointercancel', onPanelPointerUp)
@@ -1479,6 +2324,7 @@ window.__ModuleLoader__.load({
1479
2324
  document.removeEventListener('pointerup', onDocPointerUp, true)
1480
2325
  document.removeEventListener('pointercancel', onDocPointerCancel, true)
1481
2326
  document.removeEventListener('copy', onCopy, true)
2327
+ document.removeEventListener('keydown', onDocKeyDown)
1482
2328
  fab.remove()
1483
2329
  panel.remove()
1484
2330
  style.remove()
@@ -1491,14 +2337,62 @@ window.__ModuleLoader__.load({
1491
2337
  document.documentElement.removeAttribute(FS_ATTR)
1492
2338
  document.documentElement.style.removeProperty('--dsrb-font-base')
1493
2339
  document.documentElement.style.removeProperty('--dsrb-font-scale')
1494
- const t = widthTargets()
1495
- if (t) t.container.style.removeProperty('--dsh-chat-content-width')
2340
+ turndownSvc = null
2341
+ const target = widthTargets()
2342
+ if (target) target.container.style.removeProperty('--dsh-chat-content-width')
2343
+ // 仅当全局句柄仍属于本实例时才释放,避免抹掉新实例的热重载句柄
2344
+ if (window.__dsrbDisposeOwner === INSTANCE_TOKEN) {
2345
+ window.__dsrbDispose = null
2346
+ window.__dsrbDisposeOwner = null
2347
+ }
2348
+ // 插件被停用/卸载(语言切换重挂除外)时退出本插件进入的全屏,恢复窗口态
2349
+ if (!remounting && document.fullscreenElement === document.documentElement) {
2350
+ try {
2351
+ const p = document.exitFullscreen()
2352
+ if (p && typeof p.catch === 'function') p.catch(() => {})
2353
+ } catch { /* 退出失败不阻塞拆除 */ }
2354
+ }
1496
2355
  }
1497
- ctx.effect(() => dispose, 'dsh-happy-reader: 实体与监听')
1498
- window.__dsrbDispose = dispose
2356
+ // 主题/语言事件通过该句柄刷新本层 UI(apply 级用到)
2357
+ function applyTextFonts() {
2358
+ textCjkSelect.value = state.textFontCjk ?? ''
2359
+ textLatinSelect.value = state.textFontLatin ?? ''
2360
+ kickFontPick()
2361
+ renderGroupSummaries()
2362
+ }
2363
+ function refreshFontPick() { kickFontPick() }
2364
+ ui = {
2365
+ applyFont,
2366
+ renderFontRow,
2367
+ renderCodeSizeRow,
2368
+ renderCodeFontRow,
2369
+ renderTerminalSizeRow,
2370
+ renderTerminalFontRow,
2371
+ renderTextBaseRow,
2372
+ renderGroupSummaries,
2373
+ refreshFontPick,
2374
+ applyTextFonts,
2375
+ scheduleWidthRefresh,
2376
+ }
2377
+ // 热重载句柄必须连同设置页一起拆:设置行不在 mount 的 DOM 树里
2378
+ window.__dsrbDispose = () => {
2379
+ dispose()
2380
+ if (settingsDispose) {
2381
+ try { settingsDispose() } catch { /* 忽略 */ }
2382
+ settingsDispose = null
2383
+ }
2384
+ settingsHost = null
2385
+ settingsRefresh = null
2386
+ settingsAlive = false
2387
+ }
2388
+ window.__dsrbDisposeOwner = INSTANCE_TOKEN
2389
+ return dispose
1499
2390
  } catch (err) {
1500
- // 任何意外都只记日志,不影响应用本体
1501
- console.error('[dsh-happy-reader] 挂载失败:', err)
2391
+ // 任何意外都只记日志并清掉半成品,不影响应用本体
2392
+ console.error('[dsh-happy-reader] 面板实体初始化失败:', err)
2393
+ overlayAlive = false
2394
+ try { host.textContent = '' } catch { /* 忽略 */ }
2395
+ return () => {}
1502
2396
  }
1503
2397
  }
1504
2398
 
@@ -2526,6 +3420,7 @@ return exports;
2526
3420
  // ══ vendored: turndown-plugin-gfm end ══
2527
3421
 
2528
3422
  exports.apply = apply
3423
+ exports.inject = ['slots']
2529
3424
 
2530
3425
  return module.exports
2531
3426
  },