@luziyang2026/dsh-question-nav 0.5.0 → 0.7.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 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 enlarge it and
11
- see the question's **full text** immediately; click a dot to scroll the chat to
12
- that question.
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
- - **Vertically centered** in the conversation column.
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,12 +39,26 @@ 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 enlarges and an instant tooltip (portal-rendered, no
39
- native-title delay) shows the **turn label** (`Turn N`) plus the turn's
40
- **full question text** (all of them, when one turn batched several).
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 hierarchy
50
+ is two-tier: the **center card is the focus** — full-contrast text, a brand
51
+ accent bar and a raised shadow — while the four neighbors are clean
52
+ **context cards** (same surface, narrower, fewer lines) that light up on
53
+ hover to show they are clickable too. **Clicking any card jumps to that
54
+ question** exactly like clicking its dot.
41
55
  - **Click**: jumps to that question. Only then does the jump loop page the
42
56
  window (`loadOlder()`) to bring that specific page into view — never the
43
57
  whole history up front.
58
+ - **Elegant overflow scroll**: when many dots don't fit the 60% band, the
59
+ native scrollbar is hidden and the column fades out at its top and bottom
60
+ edges; hovering either fade zone gently auto-scrolls so hidden dots flow
61
+ into the clear, clickable area (wheel and trackpad still scroll too).
44
62
  - Empty/left areas of the rail pass pointer events through to the conversation
45
63
  (it never blocks the chat).
46
64
 
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,17 +24,29 @@
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
- - **悬停**:圆点放大 + 即时提示框(portal 渲染,无原生 `title` 延迟)显示
33
- **Turn 编号**(`Turn N`)和该轮提问**全文**(一轮多条提问时全部列出)。
35
+ - **悬停(聚焦)**:圆点与其**上下各两个**圆点一起渐进放大(选中最大),
36
+ 导航条自动把选中点居中;导航条旁出现一列**竖排的 5 张清晰问题卡片**
37
+ (portal 渲染,无原生 `title` 延迟)——窗口内每个圆点一张卡片,上下排布、
38
+ **互不遮挡**,以选中点为中心垂直居中。每张卡片显示该轮提问**全文**(一轮
39
+ 多条提问时全部列出)与提问的**发出时间**(当天 `HH:MM`,跨天
40
+ `MM-DD HH:MM`,跨年 `YYYY-MM-DD HH:MM`)。层级采用**两档聚焦**:
41
+ **中心卡片是焦点**——全对比度文字、品牌色左 accent bar、浮起投影;上下
42
+ 四张是干净的**上下文卡**(同背景、更窄、行数更少),hover 时会亮起、
43
+ 明示它们同样可以点击。**点击任意卡片即跳转到对应提问**,与点击圆点
44
+ 效果一致。
34
45
  - **点击**:跳转到该提问;只有这时跳转循环才 `loadOlder()` 扩窗,把**那一页**
35
46
  带进视图——绝不会一次性展开整个历史。
47
+ - **优雅的溢出滚动**:圆点太多超出 60% 限高时,原生滚动条被隐藏,点列在
48
+ 上下边缘渐隐;鼠标悬停任一渐隐区会轻柔自动滚动,把藏起来的圆点流入
49
+ 清晰可点的区域(滚轮/触控板同样可滚)。
36
50
  - 圆点列空白区域**点穿**到对话内容,不会挡住聊天。
37
51
 
38
52
  ## 环境要求
package/lib/client.js CHANGED
@@ -98,9 +98,96 @@ 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
+ };
131
+ case 1: return {
132
+ widthPx: 300,
133
+ fontSize: 12.5,
134
+ maxLines: 2
135
+ };
136
+ default: return {
137
+ widthPx: 240,
138
+ fontSize: 12,
139
+ maxLines: 1
140
+ };
141
+ }
142
+ }
143
+ /**
144
+ * Auto-scroll speed (px/s) for a pointer at `ratio` depth into an edge fade
145
+ * zone (0 = at the zone boundary, 1 = at the band's very edge): eases
146
+ * linearly from MIN to MAX so a shallow probe scrolls gently and pushing
147
+ * into the edge moves fast. The sign (direction) is applied by the caller.
148
+ */
149
+ function edgeScrollSpeed(ratio) {
150
+ return 90 + 330 * Math.max(0, Math.min(1, ratio));
151
+ }
152
+ //#endregion
153
+ //#region src/core/time.ts
154
+ /**
155
+ * Question sent-time formatting for the question-nav surface. Pure: no DSH
156
+ * imports, deterministic for a given (time, now) pair — unit-testable in
157
+ * isolation and safe to inline into the client bundle.
158
+ *
159
+ * @module dsh-question-nav/time
160
+ */
161
+ function pad2(n) {
162
+ return n < 10 ? `0${n}` : String(n);
163
+ }
164
+ /** Whether `time` falls on the same calendar day as `now`. */
165
+ function isSameDay(time, now) {
166
+ const a = new Date(time);
167
+ const b = new Date(now);
168
+ return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
169
+ }
170
+ /**
171
+ * Smart question sent-time, relative to `now`:
172
+ * - same calendar day → `HH:MM`
173
+ * - same calendar year → `MM-DD HH:MM`
174
+ * - otherwise → `YYYY-MM-DD HH:MM`
175
+ *
176
+ * Returns `''` for a missing/invalid timestamp (e.g. a live node that never
177
+ * reported one), so callers can hide the time line without branching.
178
+ */
179
+ function formatQuestionTime(time, now) {
180
+ if (!Number.isFinite(time) || time <= 0) return "";
181
+ const d = new Date(time);
182
+ const hm = `${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
183
+ if (isSameDay(time, now)) return hm;
184
+ const md = `${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
185
+ if (d.getFullYear() === new Date(now).getFullYear()) return `${md} ${hm}`;
186
+ return `${d.getFullYear()}-${md} ${hm}`;
187
+ }
101
188
  //#endregion
102
189
  //#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:1;align-items:center;gap:6px;width:100%;min-height: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{background:var(--dsw-alias-brand-primary);transform:scale(2)}.TWf_pa_dot.TWf_pa_active{background:var(--dsw-alias-brand-primary);transform:scale(1.6)}.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_tooltip{z-index:20;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);max-width:420px;color:var(--dsw-alias-label-primary);white-space:normal;word-break:break-word;pointer-events:none;border-radius:8px;padding:8px 12px;font-size:13px;line-height:18px;position:fixed;box-shadow:0 4px 16px #0003}.TWf_pa_tooltipTitle{color:var(--dsw-alias-label-tertiary);margin-bottom:4px;font-size:11px;font-weight:600}.TWf_pa_tooltipLine+.TWf_pa_tooltipLine{border-top:1px solid var(--dsw-alias-border-l1);margin-top:6px;padding-top:6px}.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)}";
190
+ 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:none;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::-webkit-scrollbar{width:0;height:0;display:none}.TWf_pa_listScrollable{-webkit-mask-image:linear-gradient(#0000,#000 22px calc(100% - 22px),#0000);mask-image:linear-gradient(#0000,#000 22px calc(100% - 22px),#0000)}.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-l2);width:380px;color:var(--dsw-alias-label-secondary);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 1px 6px #0000001a}.TWf_pa_card:hover{border-color:var(--dsw-alias-brand-primary);color:var(--dsw-alias-label-primary);box-shadow:0 3px 12px #0000002e}.TWf_pa_cardSelected{border-color:var(--dsw-alias-border-l1);color:var(--dsw-alias-label-primary);box-shadow:inset 3px 0 0 0 var(--dsw-alias-brand-primary), 0 6px 20px #00000038}.TWf_pa_cardSelected:hover{border-color:var(--dsw-alias-brand-primary);box-shadow:inset 3px 0 0 0 var(--dsw-alias-brand-primary), 0 6px 22px #00000042}.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{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
191
  const tagId = "@luziyang2026/dsh-question-nav/question-nav.module.css";
105
192
  if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
106
193
  const tag = document.createElement("style");
@@ -111,11 +198,22 @@ window.__ModuleLoader__.load({
111
198
  }
112
199
  var question_nav_module_css_default = {
113
200
  "active": "TWf_pa_active",
201
+ "card": "TWf_pa_card",
202
+ "cardBody": "TWf_pa_cardBody",
203
+ "cardClamp": "TWf_pa_cardClamp",
204
+ "cardLine": "TWf_pa_cardLine",
205
+ "cardSelected": "TWf_pa_cardSelected",
206
+ "cardTime": "TWf_pa_cardTime",
207
+ "cardTitle": "TWf_pa_cardTitle",
208
+ "cascade": "TWf_pa_cascade",
114
209
  "count": "TWf_pa_count",
115
210
  "dot": "TWf_pa_dot",
116
211
  "dots": "TWf_pa_dots",
117
212
  "empty": "TWf_pa_empty",
213
+ "focused": "TWf_pa_focused",
214
+ "hint": "TWf_pa_hint",
118
215
  "list": "TWf_pa_list",
216
+ "listScrollable": "TWf_pa_listScrollable",
119
217
  "rail": "TWf_pa_rail",
120
218
  "railRight": "TWf_pa_railRight",
121
219
  "segment": "TWf_pa_segment",
@@ -123,22 +221,26 @@ window.__ModuleLoader__.load({
123
221
  "segmented": "TWf_pa_segmented",
124
222
  "settings": "TWf_pa_settings",
125
223
  "settingsDesc": "TWf_pa_settingsDesc",
126
- "settingsTitle": "TWf_pa_settingsTitle",
127
- "tooltip": "TWf_pa_tooltip",
128
- "tooltipLine": "TWf_pa_tooltipLine",
129
- "tooltipTitle": "TWf_pa_tooltipTitle"
224
+ "settingsTitle": "TWf_pa_settingsTitle"
130
225
  };
131
226
  //#endregion
132
227
  //#region src/client/QuestionNavStrip.tsx
133
228
  /**
134
229
  * Question-nav minimap. Renders a vertical column of small round dots overlaid
135
230
  * on the LEFT edge of the conversation column (via the frame-wide
136
- * `shell.overlay` floating layer), vertically centered: one dot per turn that
137
- * claimed at least one user question — strictly aligned with the Trajectory
138
- * view's turn numbering (turns without a question produce no dot). Hover
139
- * enlarges a dot and shows an instant tooltip (portal-rendered, no native
140
- * delay) with the turn label and the turn's question text(s); clicking jumps
141
- * the chat to that turn's first question.
231
+ * `shell.overlay` floating layer): one dot per turn that claimed at least one
232
+ * user question — strictly aligned with the Trajectory view's turn numbering
233
+ * (turns without a question produce no dot). The dot column is vertically
234
+ * centered and clamped to at most 60% of the conversation height, scrolling
235
+ * within that band when the session has more dots than fit.
236
+ *
237
+ * Hovering a dot "selects" it: the selected dot plus its two immediate
238
+ * neighbors on each side magnify (progressively smaller with distance), and a
239
+ * vertical cascade of question cards opens — the selected (center) card is the
240
+ * focus (brand accent, elevated, full question text), the four neighbors are
241
+ * narrower context cards clamped to fewer lines. Every card is clickable and
242
+ * jumps to its question, exactly like clicking the dot; the rail auto-centers
243
+ * the selected dot.
142
244
  *
143
245
  * Data source: the host-folded `questionIndex` session projection (whole
144
246
  * history, persisted host-side, pushed live through session/projection
@@ -167,8 +269,8 @@ window.__ModuleLoader__.load({
167
269
  return document.querySelector("[data-slot=\"conversation\"] > div[data-phase]");
168
270
  }
169
271
  /** Structural equality of two dot lists (member keys fully capture a dot's
170
- * folded questions, so identical key sequences mean identical content).
171
- * Lets the strip skip a re-render when a refresh produced no change. */
272
+ * folded questions, so identical key sequences mean identical content).
273
+ * Lets the strip skip a re-render when a refresh produced no change. */
172
274
  function sameDots(a, b) {
173
275
  if (a.length !== b.length) return false;
174
276
  for (let i = 0; i < a.length; i++) {
@@ -186,11 +288,70 @@ window.__ModuleLoader__.load({
186
288
  const [dots, setDots] = (0, react.useState)([]);
187
289
  const [jumpingKey, setJumpingKey] = (0, react.useState)(null);
188
290
  const [hint, setHint] = (0, react.useState)(null);
189
- const [tooltip, setTooltip] = (0, react.useState)(null);
291
+ const [focus, setFocus] = (0, react.useState)(null);
190
292
  const [align, setAlign] = (0, react.useState)(() => props.align());
191
293
  const panelRef = (0, react.useRef)(null);
294
+ const listRef = (0, react.useRef)(null);
192
295
  const hintTimerRef = (0, react.useRef)(null);
296
+ const clearFocusTimerRef = (0, react.useRef)(null);
193
297
  const lastDotsRef = (0, react.useRef)([]);
298
+ const lastFocusedKeyRef = (0, react.useRef)(null);
299
+ const [scrollable, setScrollable] = (0, react.useState)(false);
300
+ const edgeScrollRef = (0, react.useRef)({
301
+ speed: 0,
302
+ raf: 0,
303
+ last: 0
304
+ });
305
+ const stopEdgeScroll = () => {
306
+ edgeScrollRef.current.speed = 0;
307
+ if (edgeScrollRef.current.raf !== 0) {
308
+ cancelAnimationFrame(edgeScrollRef.current.raf);
309
+ edgeScrollRef.current.raf = 0;
310
+ }
311
+ };
312
+ const edgeTick = (now) => {
313
+ const list = listRef.current;
314
+ const state = edgeScrollRef.current;
315
+ state.raf = 0;
316
+ if (list === null || state.speed === 0) return;
317
+ const dt = Math.min(64, now - state.last) / 1e3;
318
+ state.last = now;
319
+ list.scrollTop = list.scrollTop + state.speed * dt;
320
+ const atTop = list.scrollTop <= 0 && state.speed < 0;
321
+ const atBottom = list.scrollTop >= list.scrollHeight - list.clientHeight - 1 && state.speed > 0;
322
+ if (atTop || atBottom) {
323
+ state.speed = 0;
324
+ return;
325
+ }
326
+ state.raf = requestAnimationFrame(edgeTick);
327
+ };
328
+ const onListMouseMove = (e) => {
329
+ const list = listRef.current;
330
+ if (list === null) return;
331
+ const rect = list.getBoundingClientRect();
332
+ const y = e.clientY - rect.top;
333
+ const depthTop = 26 - y;
334
+ const depthBottom = y - (rect.height - 26);
335
+ let speed = 0;
336
+ if (depthTop > 0 && list.scrollTop > 0) speed = -edgeScrollSpeed(depthTop / 26);
337
+ else if (depthBottom > 0 && list.scrollTop < list.scrollHeight - list.clientHeight - 1) speed = edgeScrollSpeed(depthBottom / 26);
338
+ const state = edgeScrollRef.current;
339
+ state.speed = speed;
340
+ if (speed !== 0 && state.raf === 0) {
341
+ state.last = performance.now();
342
+ state.raf = requestAnimationFrame(edgeTick);
343
+ }
344
+ };
345
+ const cancelClearFocus = () => {
346
+ if (clearFocusTimerRef.current !== null) {
347
+ window.clearTimeout(clearFocusTimerRef.current);
348
+ clearFocusTimerRef.current = null;
349
+ }
350
+ };
351
+ const scheduleClearFocus = () => {
352
+ cancelClearFocus();
353
+ clearFocusTimerRef.current = window.setTimeout(() => setFocus(null), 240);
354
+ };
194
355
  (0, react.useEffect)(() => props.subscribeAlign(() => setAlign(props.align())), [props]);
195
356
  const showHint = (message) => {
196
357
  setHint(message);
@@ -315,8 +476,32 @@ window.__ModuleLoader__.load({
315
476
  window.removeEventListener("resize", wake);
316
477
  };
317
478
  }, [visible, align]);
479
+ (0, react.useLayoutEffect)(() => {
480
+ const key = focus?.key ?? null;
481
+ if (lastFocusedKeyRef.current === key) return;
482
+ lastFocusedKeyRef.current = key;
483
+ if (key === null) return;
484
+ const list = listRef.current;
485
+ if (list === null) return;
486
+ const target = list.querySelector("[data-question-nav-focused=\"true\"]");
487
+ if (target === null) return;
488
+ const center = target.offsetTop - list.offsetTop - (list.clientHeight - target.offsetHeight) / 2;
489
+ list.scrollTop = Math.max(0, Math.min(center, list.scrollHeight - list.clientHeight));
490
+ }, [focus]);
491
+ (0, react.useLayoutEffect)(() => {
492
+ const list = listRef.current;
493
+ if (list === null) return;
494
+ const check = () => setScrollable(list.scrollHeight > list.clientHeight + 1);
495
+ check();
496
+ if (typeof ResizeObserver === "undefined") return;
497
+ const observer = new ResizeObserver(check);
498
+ observer.observe(list);
499
+ return () => observer.disconnect();
500
+ }, [dots, align]);
318
501
  (0, react.useEffect)(() => () => {
319
502
  if (hintTimerRef.current !== null) window.clearTimeout(hintTimerRef.current);
503
+ if (clearFocusTimerRef.current !== null) window.clearTimeout(clearFocusTimerRef.current);
504
+ stopEdgeScroll();
320
505
  }, []);
321
506
  if (!visible) return null;
322
507
  const onJump = (dot) => {
@@ -325,28 +510,41 @@ window.__ModuleLoader__.load({
325
510
  props.jump(current, dot.key);
326
511
  window.setTimeout(() => setJumpingKey((k) => k === dot.key ? null : k), 600);
327
512
  };
328
- const openTooltip = (dot, target) => {
513
+ const openFocus = (dot, target) => {
514
+ const index = dots.findIndex((d) => d.key === dot.key);
515
+ if (index < 0) return;
516
+ cancelClearFocus();
329
517
  const r = target.getBoundingClientRect();
330
- setTooltip({
331
- title: dot.turn === null ? null : `Turn ${dot.turn}`,
332
- lines: dot.texts,
333
- ...align === "right" ? { right: window.innerWidth - r.left + 10 } : { left: r.right + 10 },
334
- top: r.top
518
+ const horizontal = align === "right" ? { right: window.innerWidth - r.left + 10 } : { left: r.right + 10 };
519
+ const lo = Math.max(0, index - 2);
520
+ const hi = Math.min(dots.length - 1, index + 2);
521
+ const items = [];
522
+ for (let i = lo; i <= hi; i++) items.push({
523
+ dot: dots[i],
524
+ distance: Math.abs(i - index)
525
+ });
526
+ setFocus({
527
+ key: dot.key,
528
+ items,
529
+ centerY: r.top + r.height / 2,
530
+ ...horizontal
335
531
  });
336
532
  };
337
533
  const t = props.t;
534
+ const selectedIndex = focus === null ? -1 : dots.findIndex((d) => d.key === focus.key);
338
535
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
339
536
  ref: panelRef,
340
537
  className: align === "right" ? `${question_nav_module_css_default.rail} ${question_nav_module_css_default.railRight}` : question_nav_module_css_default.rail,
341
538
  "data-question-nav": "rail",
342
539
  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
540
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
349
- className: question_nav_module_css_default.list,
541
+ ref: listRef,
542
+ className: scrollable ? `${question_nav_module_css_default.list} ${question_nav_module_css_default.listScrollable}` : question_nav_module_css_default.list,
543
+ onMouseMove: scrollable ? onListMouseMove : void 0,
544
+ onMouseLeave: () => {
545
+ stopEdgeScroll();
546
+ scheduleClearFocus();
547
+ },
350
548
  children: dots.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
351
549
  className: question_nav_module_css_default.empty,
352
550
  children: t("strip.empty")
@@ -355,29 +553,81 @@ window.__ModuleLoader__.load({
355
553
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
356
554
  className: question_nav_module_css_default.count,
357
555
  children: dots.length
358
- }), dots.map((dot) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
359
- className: jumpingKey === dot.key ? `${question_nav_module_css_default.dot} ${question_nav_module_css_default.active}` : question_nav_module_css_default.dot,
360
- "aria-label": dot.texts[0] ?? "",
361
- onMouseEnter: (e) => openTooltip(dot, e.currentTarget),
362
- onMouseLeave: () => setTooltip(null),
363
- onClick: () => onJump(dot)
364
- }, dot.key))]
556
+ }), dots.map((dot, index) => {
557
+ const isFocused = focus !== null && focus.key === dot.key;
558
+ const tier = selectedIndex < 0 ? null : focusTier(index - selectedIndex);
559
+ const scale = jumpingKey === dot.key ? 1.6 : tier !== null ? focusScale(tier) : 1;
560
+ const cls = [question_nav_module_css_default.dot];
561
+ if (isFocused) cls.push(question_nav_module_css_default.focused);
562
+ if (jumpingKey === dot.key) cls.push(question_nav_module_css_default.active);
563
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
564
+ className: cls.join(" "),
565
+ style: { transform: `scale(${scale})` },
566
+ "data-question-nav-focused": isFocused ? "true" : void 0,
567
+ "data-question-nav-index": index,
568
+ "aria-label": dot.texts[0] ?? "",
569
+ onMouseEnter: (e) => openFocus(dot, e.currentTarget),
570
+ onClick: () => onJump(dot)
571
+ }, dot.key);
572
+ })]
365
573
  })
366
574
  }),
367
- tooltip !== null ? (0, react_dom.createPortal)(/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
368
- className: question_nav_module_css_default.tooltip,
575
+ hint !== null ? (0, react_dom.createPortal)(/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
576
+ className: question_nav_module_css_default.hint,
577
+ style: align === "right" ? {
578
+ right: 60,
579
+ top: 20
580
+ } : {
581
+ left: 60,
582
+ top: 20
583
+ },
584
+ children: hint
585
+ }), document.body) : null,
586
+ focus !== null ? (0, react_dom.createPortal)(/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
587
+ className: question_nav_module_css_default.cascade,
369
588
  style: {
370
- left: tooltip.left,
371
- right: tooltip.right,
372
- top: tooltip.top
589
+ ...focus.left !== void 0 ? { left: focus.left } : {},
590
+ ...focus.right !== void 0 ? { right: focus.right } : {},
591
+ top: focus.centerY,
592
+ transform: "translateY(-50%)"
373
593
  },
374
- children: [tooltip.title !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
375
- className: question_nav_module_css_default.tooltipTitle,
376
- children: tooltip.title
377
- }) : null, tooltip.lines.map((line, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
378
- className: question_nav_module_css_default.tooltipLine,
379
- children: line
380
- }, index))]
594
+ onMouseEnter: cancelClearFocus,
595
+ onMouseLeave: scheduleClearFocus,
596
+ children: focus.items.map((item) => {
597
+ const metrics = focusCardMetrics(item.distance);
598
+ if (metrics === null) return null;
599
+ const isSelected = item.distance === 0;
600
+ const text = item.dot.texts.join(" · ");
601
+ const cls = isSelected ? `${question_nav_module_css_default.card} ${question_nav_module_css_default.cardSelected}` : question_nav_module_css_default.card;
602
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
603
+ className: cls,
604
+ role: "button",
605
+ tabIndex: -1,
606
+ style: { width: metrics.widthPx },
607
+ onClick: () => onJump(item.dot),
608
+ children: [
609
+ isSelected && item.dot.turn !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
610
+ className: question_nav_module_css_default.cardTitle,
611
+ children: ["Turn ", item.dot.turn]
612
+ }) : null,
613
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
614
+ className: isSelected ? question_nav_module_css_default.cardBody : question_nav_module_css_default.cardClamp,
615
+ style: isSelected ? void 0 : {
616
+ WebkitLineClamp: metrics.maxLines,
617
+ fontSize: metrics.fontSize
618
+ },
619
+ children: isSelected ? item.dot.texts.map((line, lineIndex) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
620
+ className: question_nav_module_css_default.cardLine,
621
+ children: line
622
+ }, lineIndex)) : text
623
+ }),
624
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
625
+ className: question_nav_module_css_default.cardTime,
626
+ children: formatQuestionTime(item.dot.time, Date.now())
627
+ })
628
+ ]
629
+ }, item.dot.key);
630
+ })
381
631
  }), document.body) : null
382
632
  ]
383
633
  });