@luziyang2026/dsh-question-nav 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -7
- package/README.zh.md +14 -5
- package/lib/client.js +228 -44
- package/lib/client.js.map +1 -1
- package/lib/types/core/focus.d.ts +44 -0
- package/lib/types/core/time.d.ts +19 -0
- package/package.json +2 -2
- package/src/client/QuestionNavStrip.tsx +187 -46
- package/src/client/question-nav.module.css +92 -16
- package/src/core/focus.ts +76 -0
- package/src/core/time.ts +39 -0
package/README.md
CHANGED
|
@@ -7,9 +7,11 @@
|
|
|
7
7
|
|
|
8
8
|
In-session question navigator for the [DeepSeek Harness (DSH) Web GUI][dsh]: a
|
|
9
9
|
vertical column of small round dots overlaid on the **left edge** of the
|
|
10
|
-
conversation column — one dot per user question. Hover a dot to
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
conversation column — one dot per user question. Hover a dot to magnify it
|
|
11
|
+
(together with its two neighbors on each side) and reveal a vertical cascade
|
|
12
|
+
of crisp question cards with each question's **full text** and **sent time**
|
|
13
|
+
(clicking any card jumps to it); click a dot to scroll the chat to that
|
|
14
|
+
question.
|
|
13
15
|
|
|
14
16
|
It is an external plugin bundle with zero runtime dependencies — the browser
|
|
15
17
|
half (`lib/client.js`) externalizes everything to the DSH shell, so it adds
|
|
@@ -27,7 +29,9 @@ Package: **`@luziyang2026/dsh-question-nav`** ([npm][npm] · [GitHub][github]).
|
|
|
27
29
|
anchor edge is configurable — **Settings → Plugins → Question Nav → Rail
|
|
28
30
|
alignment** switches it between the left and right edge of the conversation
|
|
29
31
|
column.
|
|
30
|
-
- **
|
|
32
|
+
- **Height-capped and scrollable**: the dot column is vertically centered and
|
|
33
|
+
uses at most **60%** of the conversation height; longer sessions scroll
|
|
34
|
+
inside that band (wheel over the dots or the gaps between them).
|
|
31
35
|
- **One dot = one turn that asked a question** (strictly aligned with the
|
|
32
36
|
Trajectory view's turn numbering), with a small count above the dot column.
|
|
33
37
|
- **Full history, persisted, no render-window expansion**: the plugin's host
|
|
@@ -35,9 +39,17 @@ Package: **`@luziyang2026/dsh-question-nav`** ([npm][npm] · [GitHub][github]).
|
|
|
35
39
|
registry folds the whole event log (read-only, the chat's paged window is
|
|
36
40
|
never touched), the official projection cache persists it across restarts,
|
|
37
41
|
and push frames deliver new questions live.
|
|
38
|
-
- **Hover**: the dot
|
|
39
|
-
|
|
40
|
-
**
|
|
42
|
+
- **Hover (focus)**: the dot and its **two neighbors on each side** magnify
|
|
43
|
+
progressively (selected largest), the rail auto-centers the selection, and a
|
|
44
|
+
**vertical cascade of five crisp question cards** (portal-rendered, no
|
|
45
|
+
native-title delay) appears beside the rail — one card per window dot,
|
|
46
|
+
stacked top-to-bottom without overlap and centered on the selected dot. Each
|
|
47
|
+
card shows the turn's **full question text** (all of them, when one turn
|
|
48
|
+
batched several) and the question's **sent time** (`HH:MM` on the same day,
|
|
49
|
+
`MM-DD HH:MM` across days, `YYYY-MM-DD HH:MM` across years). The **center
|
|
50
|
+
card is the brightest** and marked with a brand edge; cards further from it
|
|
51
|
+
are slightly narrower, dimmer and show fewer lines. **Clicking any card
|
|
52
|
+
jumps to that question** exactly like clicking its dot.
|
|
41
53
|
- **Click**: jumps to that question. Only then does the jump loop page the
|
|
42
54
|
window (`loadOlder()`) to bring that specific page into view — never the
|
|
43
55
|
whole history up front.
|
package/README.zh.md
CHANGED
|
@@ -6,8 +6,10 @@
|
|
|
6
6
|
</p>
|
|
7
7
|
|
|
8
8
|
[DeepSeek Harness (DSH) Web GUI][dsh] 的**会话内提问导航**插件:在对话栏**左侧**
|
|
9
|
-
内嵌一列竖排的圆点小按钮 ——
|
|
10
|
-
|
|
9
|
+
内嵌一列竖排的圆点小按钮 —— 每个圆点对应一个用户提问。鼠标悬停圆点会把该圆点
|
|
10
|
+
连同其**上下各两个**圆点一起放大,并弹出**竖排的清晰问题卡片**显示对应提问的
|
|
11
|
+
**全文**与**发出时间**(点击任意卡片即可跳转);点击圆点则把对话滚动跳到那一
|
|
12
|
+
提问的位置。
|
|
11
13
|
|
|
12
14
|
它是外部插件 bundle,**零运行时依赖** —— 浏览器半区(`lib/client.js`)把一切
|
|
13
15
|
外部化给 DSH 外壳,加载时只往 GUI 里加一个很小的 bundle。
|
|
@@ -22,15 +24,22 @@
|
|
|
22
24
|
|
|
23
25
|
- **左缘圆点迷你地图**:内嵌,**不占任何宽度**。导航条锚定边**可配置**——
|
|
24
26
|
**设置 → 插件 → 提问导航 → 导航条对齐**可在对话栏左/右缘之间切换。
|
|
25
|
-
-
|
|
27
|
+
- **限高 + 可滚动**:圆点列垂直居中,最多占用对话栏高度的 **60%**;会话提问
|
|
28
|
+
更多时在带内上下滚动(滚轮在圆点上或圆点间隙上都能滚)。
|
|
26
29
|
- **一个圆点 = 一个含提问的 turn**(与轨迹视图的 Turn 编号严格对齐),
|
|
27
30
|
圆点列上方有圆点数量。
|
|
28
31
|
- **完整历史、持久化、不展开渲染窗口**:插件的 host 半面注册
|
|
29
32
|
`questionIndex` 会话投影——投影注册表折叠完整事件日志(只读,对话的
|
|
30
33
|
分页窗口完全不被动),官方 projection-cache 跨重启持久化,新提问通过
|
|
31
34
|
推送帧实时到达。
|
|
32
|
-
-
|
|
33
|
-
|
|
35
|
+
- **悬停(聚焦)**:圆点与其**上下各两个**圆点一起渐进放大(选中最大),
|
|
36
|
+
导航条自动把选中点居中;导航条旁出现一列**竖排的 5 张清晰问题卡片**
|
|
37
|
+
(portal 渲染,无原生 `title` 延迟)——窗口内每个圆点一张卡片,上下排布、
|
|
38
|
+
**互不遮挡**,以选中点为中心垂直居中。每张卡片显示该轮提问**全文**(一轮
|
|
39
|
+
多条提问时全部列出)与提问的**发出时间**(当天 `HH:MM`,跨天
|
|
40
|
+
`MM-DD HH:MM`,跨年 `YYYY-MM-DD HH:MM`)。**中心卡片最亮**并带品牌色描边,
|
|
41
|
+
离中心越远的卡片稍窄、稍暗、行数更少;**点击任意卡片即跳转到对应提问**,
|
|
42
|
+
与点击圆点效果一致。
|
|
34
43
|
- **点击**:跳转到该提问;只有这时跳转循环才 `loadOlder()` 扩窗,把**那一页**
|
|
35
44
|
带进视图——绝不会一次性展开整个历史。
|
|
36
45
|
- 圆点列空白区域**点穿**到对话内容,不会挡住聊天。
|
package/lib/client.js
CHANGED
|
@@ -98,9 +98,90 @@ window.__ModuleLoader__.load({
|
|
|
98
98
|
}
|
|
99
99
|
return out;
|
|
100
100
|
}
|
|
101
|
+
/** Magnification scale per tier, by distance from the selected dot
|
|
102
|
+
* (0 = selected, 1 = immediate neighbor, 2 = outer window edge). */
|
|
103
|
+
const FOCUS_SCALES = [
|
|
104
|
+
2,
|
|
105
|
+
1.55,
|
|
106
|
+
1.25
|
|
107
|
+
];
|
|
108
|
+
/**
|
|
109
|
+
* The focus tier of a dot at `distance` from the selected dot: 0..FOCUS_RADIUS
|
|
110
|
+
* while inside the magnification window, null beyond it (base scale).
|
|
111
|
+
*/
|
|
112
|
+
function focusTier(distance) {
|
|
113
|
+
const d = Math.abs(distance);
|
|
114
|
+
if (d > 2) return null;
|
|
115
|
+
return d;
|
|
116
|
+
}
|
|
117
|
+
/** Magnification scale for a dot at `distance`; 1 (base) outside the window. */
|
|
118
|
+
function focusScale(distance) {
|
|
119
|
+
const tier = focusTier(distance);
|
|
120
|
+
return tier === null ? 1 : FOCUS_SCALES[tier];
|
|
121
|
+
}
|
|
122
|
+
function focusCardMetrics(distance) {
|
|
123
|
+
const tier = focusTier(distance);
|
|
124
|
+
if (tier === null) return null;
|
|
125
|
+
switch (tier) {
|
|
126
|
+
case 0: return {
|
|
127
|
+
widthPx: 380,
|
|
128
|
+
fontSize: 13,
|
|
129
|
+
maxLines: 6,
|
|
130
|
+
brightness: 1
|
|
131
|
+
};
|
|
132
|
+
case 1: return {
|
|
133
|
+
widthPx: 300,
|
|
134
|
+
fontSize: 12.5,
|
|
135
|
+
maxLines: 2,
|
|
136
|
+
brightness: .82
|
|
137
|
+
};
|
|
138
|
+
default: return {
|
|
139
|
+
widthPx: 240,
|
|
140
|
+
fontSize: 12,
|
|
141
|
+
maxLines: 1,
|
|
142
|
+
brightness: .68
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
//#endregion
|
|
147
|
+
//#region src/core/time.ts
|
|
148
|
+
/**
|
|
149
|
+
* Question sent-time formatting for the question-nav surface. Pure: no DSH
|
|
150
|
+
* imports, deterministic for a given (time, now) pair — unit-testable in
|
|
151
|
+
* isolation and safe to inline into the client bundle.
|
|
152
|
+
*
|
|
153
|
+
* @module dsh-question-nav/time
|
|
154
|
+
*/
|
|
155
|
+
function pad2(n) {
|
|
156
|
+
return n < 10 ? `0${n}` : String(n);
|
|
157
|
+
}
|
|
158
|
+
/** Whether `time` falls on the same calendar day as `now`. */
|
|
159
|
+
function isSameDay(time, now) {
|
|
160
|
+
const a = new Date(time);
|
|
161
|
+
const b = new Date(now);
|
|
162
|
+
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Smart question sent-time, relative to `now`:
|
|
166
|
+
* - same calendar day → `HH:MM`
|
|
167
|
+
* - same calendar year → `MM-DD HH:MM`
|
|
168
|
+
* - otherwise → `YYYY-MM-DD HH:MM`
|
|
169
|
+
*
|
|
170
|
+
* Returns `''` for a missing/invalid timestamp (e.g. a live node that never
|
|
171
|
+
* reported one), so callers can hide the time line without branching.
|
|
172
|
+
*/
|
|
173
|
+
function formatQuestionTime(time, now) {
|
|
174
|
+
if (!Number.isFinite(time) || time <= 0) return "";
|
|
175
|
+
const d = new Date(time);
|
|
176
|
+
const hm = `${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
|
|
177
|
+
if (isSameDay(time, now)) return hm;
|
|
178
|
+
const md = `${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
|
|
179
|
+
if (d.getFullYear() === new Date(now).getFullYear()) return `${md} ${hm}`;
|
|
180
|
+
return `${d.getFullYear()}-${md} ${hm}`;
|
|
181
|
+
}
|
|
101
182
|
//#endregion
|
|
102
183
|
//#region \0dsh-css:dsh-question-nav/src/client/question-nav.module.css.mjs
|
|
103
|
-
const css = ".TWf_pa_rail{z-index:1;box-sizing:border-box;pointer-events:none;background:0 0;flex-direction:column;align-items:center;width:44px;display:flex;position:absolute;top:0;bottom:0;left:0}.TWf_pa_railRight{left:auto}.TWf_pa_list{scrollbar-width:thin;flex-direction:column;flex:
|
|
184
|
+
const css = ".TWf_pa_rail{z-index:1;box-sizing:border-box;pointer-events:none;background:0 0;flex-direction:column;align-items:center;width:44px;display:flex;position:absolute;top:0;bottom:0;left:0}.TWf_pa_railRight{left:auto}.TWf_pa_list{scrollbar-width:thin;pointer-events:auto;flex-direction:column;flex:0 auto;align-items:center;gap:6px;width:100%;min-height:0;max-height:60%;margin:auto 0;padding:8px 0;display:flex;overflow:hidden auto}.TWf_pa_list>:first-child{margin-top:auto}.TWf_pa_list>:last-child{margin-bottom:auto}.TWf_pa_dot{pointer-events:auto;background:var(--dsw-alias-border-l3);cursor:pointer;border:none;border-radius:50%;flex:none;width:8px;height:8px;padding:0;transition:transform .12s,background .12s}.TWf_pa_dot:hover,.TWf_pa_dot.TWf_pa_focused,.TWf_pa_dot.TWf_pa_active{background:var(--dsw-alias-brand-primary)}.TWf_pa_count{color:var(--dsw-alias-label-tertiary);user-select:none;flex:none;font-size:10px;font-weight:600;line-height:1}.TWf_pa_dots{flex-direction:column;align-items:center;gap:6px;display:flex}.TWf_pa_empty{color:var(--dsw-alias-label-tertiary);text-align:center;word-break:break-word;padding:10px 4px;font-size:11px}.TWf_pa_hint{z-index:30;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);max-width:260px;color:var(--dsw-alias-label-secondary);pointer-events:none;border-radius:6px;padding:6px 10px;font-size:12px;line-height:16px;position:fixed;box-shadow:0 2px 10px #0000002e}.TWf_pa_cascade{z-index:20;pointer-events:none;flex-direction:column;align-items:flex-start;gap:6px;display:flex;position:fixed}.TWf_pa_card{background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);width:380px;color:var(--dsw-alias-label-primary);white-space:normal;word-break:break-word;pointer-events:auto;cursor:pointer;border-radius:10px;padding:8px 12px 10px;font-size:13px;line-height:18px;transition:border-color .12s,box-shadow .12s;box-shadow:0 4px 14px #0000002e}.TWf_pa_card:hover{border-color:var(--dsw-alias-brand-primary);box-shadow:0 4px 16px #00000042}.TWf_pa_cardSelected{border-color:var(--dsw-alias-brand-primary)}.TWf_pa_cardTitle{color:var(--dsw-alias-label-tertiary);margin-bottom:4px;font-size:11px;font-weight:600}.TWf_pa_cardBody{scrollbar-width:thin;max-height:96px;overflow-y:auto}.TWf_pa_cardClamp{color:var(--dsw-alias-label-primary);word-break:break-word;-webkit-box-orient:vertical;font-size:13px;line-height:18px;display:-webkit-box;overflow:hidden}.TWf_pa_cardLine+.TWf_pa_cardLine{border-top:1px solid var(--dsw-alias-border-l1);margin-top:6px;padding-top:6px}.TWf_pa_cardTime{color:var(--dsw-alias-label-tertiary);margin-top:6px;font-size:11px}.TWf_pa_settings{flex-direction:column;gap:8px;padding:4px 0;display:flex}.TWf_pa_settingsTitle{color:var(--dsw-alias-label-primary);margin:0;font-size:13px;font-weight:600}.TWf_pa_settingsDesc{color:var(--dsw-alias-label-secondary);margin:0;font-size:12px;line-height:18px}.TWf_pa_segmented{border:1px solid var(--dsw-alias-border-l1);border-radius:8px;align-self:flex-start;display:inline-flex;overflow:hidden}.TWf_pa_segment{color:var(--dsw-alias-label-secondary);cursor:pointer;background:0 0;border:none;padding:8px 16px;font-size:13px;line-height:1;transition:background .12s,color .12s}.TWf_pa_segment+.TWf_pa_segment{border-left:1px solid var(--dsw-alias-border-l1)}.TWf_pa_segment:hover{background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary)}.TWf_pa_segmentActive,.TWf_pa_segmentActive:hover{background:var(--dsw-alias-brand-primary);color:var(--dsw-alias-label-inverse)}";
|
|
104
185
|
const tagId = "@luziyang2026/dsh-question-nav/question-nav.module.css";
|
|
105
186
|
if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
|
|
106
187
|
const tag = document.createElement("style");
|
|
@@ -111,10 +192,20 @@ window.__ModuleLoader__.load({
|
|
|
111
192
|
}
|
|
112
193
|
var question_nav_module_css_default = {
|
|
113
194
|
"active": "TWf_pa_active",
|
|
195
|
+
"card": "TWf_pa_card",
|
|
196
|
+
"cardBody": "TWf_pa_cardBody",
|
|
197
|
+
"cardClamp": "TWf_pa_cardClamp",
|
|
198
|
+
"cardLine": "TWf_pa_cardLine",
|
|
199
|
+
"cardSelected": "TWf_pa_cardSelected",
|
|
200
|
+
"cardTime": "TWf_pa_cardTime",
|
|
201
|
+
"cardTitle": "TWf_pa_cardTitle",
|
|
202
|
+
"cascade": "TWf_pa_cascade",
|
|
114
203
|
"count": "TWf_pa_count",
|
|
115
204
|
"dot": "TWf_pa_dot",
|
|
116
205
|
"dots": "TWf_pa_dots",
|
|
117
206
|
"empty": "TWf_pa_empty",
|
|
207
|
+
"focused": "TWf_pa_focused",
|
|
208
|
+
"hint": "TWf_pa_hint",
|
|
118
209
|
"list": "TWf_pa_list",
|
|
119
210
|
"rail": "TWf_pa_rail",
|
|
120
211
|
"railRight": "TWf_pa_railRight",
|
|
@@ -123,22 +214,25 @@ window.__ModuleLoader__.load({
|
|
|
123
214
|
"segmented": "TWf_pa_segmented",
|
|
124
215
|
"settings": "TWf_pa_settings",
|
|
125
216
|
"settingsDesc": "TWf_pa_settingsDesc",
|
|
126
|
-
"settingsTitle": "TWf_pa_settingsTitle"
|
|
127
|
-
"tooltip": "TWf_pa_tooltip",
|
|
128
|
-
"tooltipLine": "TWf_pa_tooltipLine",
|
|
129
|
-
"tooltipTitle": "TWf_pa_tooltipTitle"
|
|
217
|
+
"settingsTitle": "TWf_pa_settingsTitle"
|
|
130
218
|
};
|
|
131
219
|
//#endregion
|
|
132
220
|
//#region src/client/QuestionNavStrip.tsx
|
|
133
221
|
/**
|
|
134
222
|
* Question-nav minimap. Renders a vertical column of small round dots overlaid
|
|
135
223
|
* on the LEFT edge of the conversation column (via the frame-wide
|
|
136
|
-
* `shell.overlay` floating layer)
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
224
|
+
* `shell.overlay` floating layer): one dot per turn that claimed at least one
|
|
225
|
+
* user question — strictly aligned with the Trajectory view's turn numbering
|
|
226
|
+
* (turns without a question produce no dot). The dot column is vertically
|
|
227
|
+
* centered and clamped to at most 60% of the conversation height, scrolling
|
|
228
|
+
* within that band when the session has more dots than fit.
|
|
229
|
+
*
|
|
230
|
+
* Hovering a dot "selects" it: the selected dot plus its two immediate
|
|
231
|
+
* neighbors on each side magnify (progressively smaller with distance), the
|
|
232
|
+
* selected dot reveals a frosted question card (turn label, full question
|
|
233
|
+
* text, sent time) with a decorative stack of blurred cards fanning out
|
|
234
|
+
* below it, and the rail auto-centers the selected dot. Clicking jumps the
|
|
235
|
+
* chat to that turn's first question.
|
|
142
236
|
*
|
|
143
237
|
* Data source: the host-folded `questionIndex` session projection (whole
|
|
144
238
|
* history, persisted host-side, pushed live through session/projection
|
|
@@ -167,8 +261,8 @@ window.__ModuleLoader__.load({
|
|
|
167
261
|
return document.querySelector("[data-slot=\"conversation\"] > div[data-phase]");
|
|
168
262
|
}
|
|
169
263
|
/** Structural equality of two dot lists (member keys fully capture a dot's
|
|
170
|
-
*
|
|
171
|
-
*
|
|
264
|
+
* folded questions, so identical key sequences mean identical content).
|
|
265
|
+
* Lets the strip skip a re-render when a refresh produced no change. */
|
|
172
266
|
function sameDots(a, b) {
|
|
173
267
|
if (a.length !== b.length) return false;
|
|
174
268
|
for (let i = 0; i < a.length; i++) {
|
|
@@ -186,11 +280,24 @@ window.__ModuleLoader__.load({
|
|
|
186
280
|
const [dots, setDots] = (0, react.useState)([]);
|
|
187
281
|
const [jumpingKey, setJumpingKey] = (0, react.useState)(null);
|
|
188
282
|
const [hint, setHint] = (0, react.useState)(null);
|
|
189
|
-
const [
|
|
283
|
+
const [focus, setFocus] = (0, react.useState)(null);
|
|
190
284
|
const [align, setAlign] = (0, react.useState)(() => props.align());
|
|
191
285
|
const panelRef = (0, react.useRef)(null);
|
|
286
|
+
const listRef = (0, react.useRef)(null);
|
|
192
287
|
const hintTimerRef = (0, react.useRef)(null);
|
|
288
|
+
const clearFocusTimerRef = (0, react.useRef)(null);
|
|
193
289
|
const lastDotsRef = (0, react.useRef)([]);
|
|
290
|
+
const lastFocusedKeyRef = (0, react.useRef)(null);
|
|
291
|
+
const cancelClearFocus = () => {
|
|
292
|
+
if (clearFocusTimerRef.current !== null) {
|
|
293
|
+
window.clearTimeout(clearFocusTimerRef.current);
|
|
294
|
+
clearFocusTimerRef.current = null;
|
|
295
|
+
}
|
|
296
|
+
};
|
|
297
|
+
const scheduleClearFocus = () => {
|
|
298
|
+
cancelClearFocus();
|
|
299
|
+
clearFocusTimerRef.current = window.setTimeout(() => setFocus(null), 240);
|
|
300
|
+
};
|
|
194
301
|
(0, react.useEffect)(() => props.subscribeAlign(() => setAlign(props.align())), [props]);
|
|
195
302
|
const showHint = (message) => {
|
|
196
303
|
setHint(message);
|
|
@@ -315,8 +422,21 @@ window.__ModuleLoader__.load({
|
|
|
315
422
|
window.removeEventListener("resize", wake);
|
|
316
423
|
};
|
|
317
424
|
}, [visible, align]);
|
|
425
|
+
(0, react.useLayoutEffect)(() => {
|
|
426
|
+
const key = focus?.key ?? null;
|
|
427
|
+
if (lastFocusedKeyRef.current === key) return;
|
|
428
|
+
lastFocusedKeyRef.current = key;
|
|
429
|
+
if (key === null) return;
|
|
430
|
+
const list = listRef.current;
|
|
431
|
+
if (list === null) return;
|
|
432
|
+
const target = list.querySelector("[data-question-nav-focused=\"true\"]");
|
|
433
|
+
if (target === null) return;
|
|
434
|
+
const center = target.offsetTop - list.offsetTop - (list.clientHeight - target.offsetHeight) / 2;
|
|
435
|
+
list.scrollTop = Math.max(0, Math.min(center, list.scrollHeight - list.clientHeight));
|
|
436
|
+
}, [focus]);
|
|
318
437
|
(0, react.useEffect)(() => () => {
|
|
319
438
|
if (hintTimerRef.current !== null) window.clearTimeout(hintTimerRef.current);
|
|
439
|
+
if (clearFocusTimerRef.current !== null) window.clearTimeout(clearFocusTimerRef.current);
|
|
320
440
|
}, []);
|
|
321
441
|
if (!visible) return null;
|
|
322
442
|
const onJump = (dot) => {
|
|
@@ -325,28 +445,37 @@ window.__ModuleLoader__.load({
|
|
|
325
445
|
props.jump(current, dot.key);
|
|
326
446
|
window.setTimeout(() => setJumpingKey((k) => k === dot.key ? null : k), 600);
|
|
327
447
|
};
|
|
328
|
-
const
|
|
448
|
+
const openFocus = (dot, target) => {
|
|
449
|
+
const index = dots.findIndex((d) => d.key === dot.key);
|
|
450
|
+
if (index < 0) return;
|
|
451
|
+
cancelClearFocus();
|
|
329
452
|
const r = target.getBoundingClientRect();
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
453
|
+
const horizontal = align === "right" ? { right: window.innerWidth - r.left + 10 } : { left: r.right + 10 };
|
|
454
|
+
const lo = Math.max(0, index - 2);
|
|
455
|
+
const hi = Math.min(dots.length - 1, index + 2);
|
|
456
|
+
const items = [];
|
|
457
|
+
for (let i = lo; i <= hi; i++) items.push({
|
|
458
|
+
dot: dots[i],
|
|
459
|
+
distance: Math.abs(i - index)
|
|
460
|
+
});
|
|
461
|
+
setFocus({
|
|
462
|
+
key: dot.key,
|
|
463
|
+
items,
|
|
464
|
+
centerY: r.top + r.height / 2,
|
|
465
|
+
...horizontal
|
|
335
466
|
});
|
|
336
467
|
};
|
|
337
468
|
const t = props.t;
|
|
469
|
+
const selectedIndex = focus === null ? -1 : dots.findIndex((d) => d.key === focus.key);
|
|
338
470
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
339
471
|
ref: panelRef,
|
|
340
472
|
className: align === "right" ? `${question_nav_module_css_default.rail} ${question_nav_module_css_default.railRight}` : question_nav_module_css_default.rail,
|
|
341
473
|
"data-question-nav": "rail",
|
|
342
474
|
children: [
|
|
343
|
-
hint !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
344
|
-
className: question_nav_module_css_default.hint,
|
|
345
|
-
role: "status",
|
|
346
|
-
children: hint
|
|
347
|
-
}) : null,
|
|
348
475
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
476
|
+
ref: listRef,
|
|
349
477
|
className: question_nav_module_css_default.list,
|
|
478
|
+
onMouseLeave: scheduleClearFocus,
|
|
350
479
|
children: dots.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
351
480
|
className: question_nav_module_css_default.empty,
|
|
352
481
|
children: t("strip.empty")
|
|
@@ -355,29 +484,84 @@ window.__ModuleLoader__.load({
|
|
|
355
484
|
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
356
485
|
className: question_nav_module_css_default.count,
|
|
357
486
|
children: dots.length
|
|
358
|
-
}), dots.map((dot) =>
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
487
|
+
}), dots.map((dot, index) => {
|
|
488
|
+
const isFocused = focus !== null && focus.key === dot.key;
|
|
489
|
+
const tier = selectedIndex < 0 ? null : focusTier(index - selectedIndex);
|
|
490
|
+
const scale = jumpingKey === dot.key ? 1.6 : tier !== null ? focusScale(tier) : 1;
|
|
491
|
+
const cls = [question_nav_module_css_default.dot];
|
|
492
|
+
if (isFocused) cls.push(question_nav_module_css_default.focused);
|
|
493
|
+
if (jumpingKey === dot.key) cls.push(question_nav_module_css_default.active);
|
|
494
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
495
|
+
className: cls.join(" "),
|
|
496
|
+
style: { transform: `scale(${scale})` },
|
|
497
|
+
"data-question-nav-focused": isFocused ? "true" : void 0,
|
|
498
|
+
"data-question-nav-index": index,
|
|
499
|
+
"aria-label": dot.texts[0] ?? "",
|
|
500
|
+
onMouseEnter: (e) => openFocus(dot, e.currentTarget),
|
|
501
|
+
onClick: () => onJump(dot)
|
|
502
|
+
}, dot.key);
|
|
503
|
+
})]
|
|
365
504
|
})
|
|
366
505
|
}),
|
|
367
|
-
|
|
368
|
-
className: question_nav_module_css_default.
|
|
506
|
+
hint !== null ? (0, react_dom.createPortal)(/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
507
|
+
className: question_nav_module_css_default.hint,
|
|
508
|
+
style: align === "right" ? {
|
|
509
|
+
right: 60,
|
|
510
|
+
top: 20
|
|
511
|
+
} : {
|
|
512
|
+
left: 60,
|
|
513
|
+
top: 20
|
|
514
|
+
},
|
|
515
|
+
children: hint
|
|
516
|
+
}), document.body) : null,
|
|
517
|
+
focus !== null ? (0, react_dom.createPortal)(/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
518
|
+
className: question_nav_module_css_default.cascade,
|
|
369
519
|
style: {
|
|
370
|
-
left:
|
|
371
|
-
right:
|
|
372
|
-
top:
|
|
520
|
+
...focus.left !== void 0 ? { left: focus.left } : {},
|
|
521
|
+
...focus.right !== void 0 ? { right: focus.right } : {},
|
|
522
|
+
top: focus.centerY,
|
|
523
|
+
transform: "translateY(-50%)"
|
|
373
524
|
},
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
525
|
+
onMouseEnter: cancelClearFocus,
|
|
526
|
+
onMouseLeave: scheduleClearFocus,
|
|
527
|
+
children: focus.items.map((item) => {
|
|
528
|
+
const metrics = focusCardMetrics(item.distance);
|
|
529
|
+
if (metrics === null) return null;
|
|
530
|
+
const isSelected = item.distance === 0;
|
|
531
|
+
const text = item.dot.texts.join(" · ");
|
|
532
|
+
const cls = isSelected ? `${question_nav_module_css_default.card} ${question_nav_module_css_default.cardSelected}` : question_nav_module_css_default.card;
|
|
533
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
534
|
+
className: cls,
|
|
535
|
+
role: "button",
|
|
536
|
+
tabIndex: -1,
|
|
537
|
+
style: {
|
|
538
|
+
width: metrics.widthPx,
|
|
539
|
+
filter: `brightness(${metrics.brightness})`
|
|
540
|
+
},
|
|
541
|
+
onClick: () => onJump(item.dot),
|
|
542
|
+
children: [
|
|
543
|
+
isSelected && item.dot.turn !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
544
|
+
className: question_nav_module_css_default.cardTitle,
|
|
545
|
+
children: ["Turn ", item.dot.turn]
|
|
546
|
+
}) : null,
|
|
547
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
548
|
+
className: isSelected ? question_nav_module_css_default.cardBody : question_nav_module_css_default.cardClamp,
|
|
549
|
+
style: isSelected ? void 0 : {
|
|
550
|
+
WebkitLineClamp: metrics.maxLines,
|
|
551
|
+
fontSize: metrics.fontSize
|
|
552
|
+
},
|
|
553
|
+
children: isSelected ? item.dot.texts.map((line, lineIndex) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
554
|
+
className: question_nav_module_css_default.cardLine,
|
|
555
|
+
children: line
|
|
556
|
+
}, lineIndex)) : text
|
|
557
|
+
}),
|
|
558
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
559
|
+
className: question_nav_module_css_default.cardTime,
|
|
560
|
+
children: formatQuestionTime(item.dot.time, Date.now())
|
|
561
|
+
})
|
|
562
|
+
]
|
|
563
|
+
}, item.dot.key);
|
|
564
|
+
})
|
|
381
565
|
}), document.body) : null
|
|
382
566
|
]
|
|
383
567
|
});
|