@luziyang2026/dsh-question-nav 0.7.2 → 0.7.4

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
@@ -21,7 +21,11 @@ Package: **`@luziyang2026/dsh-question-nav`** ([npm][npm] · [GitHub][github]).
21
21
 
22
22
  ## Preview
23
23
 
24
- ![Full DSH Web GUI screenshot with the question-nav dot rail embedded on the left edge of the conversation column](https://raw.githubusercontent.com/AbelKeithsun/dsh-question-nav/main/docs/images/question-nav-preview.jpg)
24
+ ![Full DSH Web GUI screenshot with the question-nav dot rail embedded on the left edge of the conversation column](https://raw.githubusercontent.com/AbelKeithsun/dsh-question-nav/main/docs/images/0.7.4%E6%95%88%E6%9E%9C%E9%A2%84%E8%A7%88.jpg)
25
+
26
+ Plugin settings (rail alignment + dots per page):
27
+
28
+ ![The plugin's settings page: rail alignment and dots-per-page segmented controls](https://raw.githubusercontent.com/AbelKeithsun/dsh-question-nav/main/docs/images/%E6%8F%92%E4%BB%B6%E9%85%8D%E7%BD%AE%E5%8F%82%E6%95%B0%E8%AE%BE%E7%BD%AE.jpg)
25
29
 
26
30
  ## What it does
27
31
 
@@ -58,9 +62,13 @@ Package: **`@luziyang2026/dsh-question-nav`** ([npm][npm] · [GitHub][github]).
58
62
  whole history up front.
59
63
  - **Paged overflow**: when many dots don't fit the 60% band, the native
60
64
  scrollbar is hidden and two small triangle buttons in the dot style appear —
61
- **▲ above the dot queue and ▼ below it** — each click revealing five hidden
62
- dots. The column fades at its top and bottom edges as a pure visual cue
63
- (wheel and trackpad still scroll too); hovering never auto-scrolls.
65
+ **▲ above the dot queue and ▼ below it** — each click revealing the next
66
+ page of hidden dots, which pop in with a short staggered animation as click
67
+ feedback. The page size is configurable **Settings → Plugins → Question
68
+ Nav → Dots per page** (3 / 5 / 8 / 10, default 5). The triangles themselves
69
+ are the overflow cue — each appears only while its direction has more dots
70
+ to reveal (wheel and trackpad still scroll too); **hovering never scrolls
71
+ the band**, so the dots stay put while you browse.
64
72
  - Empty/left areas of the rail pass pointer events through to the conversation
65
73
  (it never blocks the chat).
66
74
 
package/README.zh.md CHANGED
@@ -18,7 +18,11 @@
18
18
 
19
19
  ## 效果展示
20
20
 
21
- ![DSH Web GUI 完整截图:对话栏左侧内嵌提问导航圆点列,每个圆点对应一个用户提问](https://raw.githubusercontent.com/AbelKeithsun/dsh-question-nav/main/docs/images/question-nav-preview.jpg)
21
+ ![DSH Web GUI 完整截图:对话栏左侧内嵌提问导航圆点列,每个圆点对应一个用户提问](https://raw.githubusercontent.com/AbelKeithsun/dsh-question-nav/main/docs/images/0.7.4%E6%95%88%E6%9E%9C%E9%A2%84%E8%A7%88.jpg)
22
+
23
+ 插件设置页(导航条对齐 + 每次翻出数量):
24
+
25
+ ![插件设置页截图:导航条对齐与每次翻出数量两组分段选项](https://raw.githubusercontent.com/AbelKeithsun/dsh-question-nav/main/docs/images/%E6%8F%92%E4%BB%B6%E9%85%8D%E7%BD%AE%E5%8F%82%E6%95%B0%E8%AE%BE%E7%BD%AE.jpg)
22
26
 
23
27
  ## 功能
24
28
 
@@ -47,8 +51,11 @@
47
51
  带进视图——绝不会一次性展开整个历史。
48
52
  - **分页式溢出**:圆点太多超出 60% 限高时,原生滚动条被隐藏,点列上下会出现
49
53
  两个与圆点同风格的三角按钮——**队列上方 ▲、队列下方 ▼**——每次点击翻出
50
- 5 个隐藏圆点。点列上下边缘的渐隐只是纯视觉提示(滚轮/触控板同样可滚);
51
- 悬停不再自动滚动。
54
+ 下一页隐藏圆点,新翻出的圆点会带一个简短的逐个点弹入动画作为点击反馈。
55
+ 每页数量可在 **设置 → 插件 → 提问导航 → 每次翻出数量** 中配置
56
+ (3 / 5 / 8 / 10,默认 5)。三角按钮本身就是溢出提示——只有对应方向还有
57
+ 更多圆点时它才会出现(滚轮/触控板同样可滚);**悬停绝不滚动点列**,
58
+ 浏览时点列保持不动。
52
59
  - 圆点列空白区域**点穿**到对话内容,不会挡住聊天。
53
60
 
54
61
  ## 环境要求
package/lib/client.js CHANGED
@@ -140,22 +140,6 @@ window.__ModuleLoader__.load({
140
140
  };
141
141
  }
142
142
  }
143
- /**
144
- * Minimal "scroll into view" for the focused dot, in the spirit of
145
- * scrollIntoView({ block: 'nearest' }): returns the scrollTop that brings the
146
- * dot — plus `clearance` room for its magnified neighbors — inside the
147
- * visible band with the smallest possible movement, or null when the dot is
148
- * already fully visible. Unlike unconditional centering this never shifts the
149
- * band while the user browses dot-by-dot: only a clipped dot is scrolled.
150
- */
151
- function minimalScrollIntoView(scrollTop, clientHeight, scrollHeight, dotTop, dotHeight, clearance = 50) {
152
- const margin = Math.min(clearance, clientHeight / 4);
153
- const viewTop = scrollTop + margin;
154
- const viewBottom = scrollTop + clientHeight - margin;
155
- if (dotTop >= viewTop && dotTop + dotHeight <= viewBottom) return null;
156
- const next = dotTop < viewTop ? dotTop - margin : dotTop + dotHeight + margin - clientHeight;
157
- return Math.max(0, Math.min(next, Math.max(0, scrollHeight - clientHeight)));
158
- }
159
143
  /** Scroll step (px) for one paging click: `rows` whole dot rows. */
160
144
  function pageStep(dotSize = 8, gap = 6, rows = 5) {
161
145
  return rows * (dotSize + gap);
@@ -202,7 +186,7 @@ window.__ModuleLoader__.load({
202
186
  }
203
187
  //#endregion
204
188
  //#region \0dsh-css:dsh-question-nav/src/client/question-nav.module.css.mjs
205
- 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_queue{box-sizing:border-box;pointer-events:none;flex-direction:column;flex:0 auto;align-items:center;gap:6px;width:100%;min-height:0;max-height:60%;margin:auto 0;display:flex}.TWf_pa_list{scrollbar-width:none;pointer-events:auto;flex-direction:column;flex:auto;align-items:center;gap:6px;width:100%;min-height: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_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_navBtn{pointer-events:auto;cursor:pointer;background:0 0;border:none;flex:none;justify-content:center;align-items:center;width:14px;height:12px;padding:0;display:flex}.TWf_pa_navBtn:before{content:\"\";border-left:5px solid #0000;border-right:5px solid #0000;width:0;height:0;transition:border-color .12s;display:block}.TWf_pa_navUp:before{border-bottom:7px solid var(--dsw-alias-border-l3)}.TWf_pa_navDown:before{border-top:7px solid var(--dsw-alias-border-l3)}.TWf_pa_navUp:hover:before{border-bottom-color:var(--dsw-alias-brand-primary)}.TWf_pa_navDown:hover:before{border-top-color:var(--dsw-alias-brand-primary)}.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)}";
189
+ 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_queue{box-sizing:border-box;pointer-events:none;flex-direction:column;flex:0 auto;align-items:center;gap:6px;width:100%;min-height:0;max-height:60%;margin:auto 0;display:flex}.TWf_pa_list{scrollbar-width:none;pointer-events:auto;flex-direction:column;flex:auto;align-items:center;gap:6px;width:100%;min-height:0;padding:8px 0;display:flex;overflow:hidden auto}.TWf_pa_list::-webkit-scrollbar{width:0;height:0;display:none}.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)}@keyframes TWf_pa_dotEnterFromBottom{0%{opacity:0;transform:translateY(7px)scale(.4)}60%{opacity:1}to{opacity:1;transform:translateY(0)scale(1)}}@keyframes TWf_pa_dotEnterFromTop{0%{opacity:0;transform:translateY(-7px)scale(.4)}60%{opacity:1}to{opacity:1;transform:translateY(0)scale(1)}}.TWf_pa_dotEnterFromBottom{animation:.32s cubic-bezier(.2,.9,.3,1.2) both TWf_pa_dotEnterFromBottom}.TWf_pa_dotEnterFromTop{animation:.32s cubic-bezier(.2,.9,.3,1.2) both TWf_pa_dotEnterFromTop}@media (prefers-reduced-motion:reduce){.TWf_pa_dotEnterFromBottom,.TWf_pa_dotEnterFromTop{animation:none}}.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_navBtn{pointer-events:auto;cursor:pointer;background:0 0;border:none;flex:none;justify-content:center;align-items:center;width:14px;height:12px;padding:0;display:flex}.TWf_pa_navBtn:before{content:\"\";border-left:5px solid #0000;border-right:5px solid #0000;width:0;height:0;transition:border-color .12s;display:block}.TWf_pa_navUp:before{border-bottom:7px solid var(--dsw-alias-border-l3)}.TWf_pa_navDown:before{border-top:7px solid var(--dsw-alias-border-l3)}.TWf_pa_navUp:hover:before{border-bottom-color:var(--dsw-alias-brand-primary)}.TWf_pa_navDown:hover:before{border-top-color:var(--dsw-alias-brand-primary)}.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-3);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-3);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_segmented+.TWf_pa_settingsTitle{margin-top:12px}.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)}";
206
190
  const tagId = "@luziyang2026/dsh-question-nav/question-nav.module.css";
207
191
  if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
208
192
  const tag = document.createElement("style");
@@ -223,12 +207,13 @@ window.__ModuleLoader__.load({
223
207
  "cascade": "TWf_pa_cascade",
224
208
  "count": "TWf_pa_count",
225
209
  "dot": "TWf_pa_dot",
210
+ "dotEnterFromBottom": "TWf_pa_dotEnterFromBottom",
211
+ "dotEnterFromTop": "TWf_pa_dotEnterFromTop",
226
212
  "dots": "TWf_pa_dots",
227
213
  "empty": "TWf_pa_empty",
228
214
  "focused": "TWf_pa_focused",
229
215
  "hint": "TWf_pa_hint",
230
216
  "list": "TWf_pa_list",
231
- "listScrollable": "TWf_pa_listScrollable",
232
217
  "navBtn": "TWf_pa_navBtn",
233
218
  "navDown": "TWf_pa_navDown",
234
219
  "navUp": "TWf_pa_navUp",
@@ -258,13 +243,16 @@ window.__ModuleLoader__.load({
258
243
  * vertical cascade of question cards opens — the selected (center) card is the
259
244
  * focus (brand accent, elevated, full question text), the four neighbors are
260
245
  * narrower context cards clamped to fewer lines. Every card is clickable and
261
- * jumps to its question, exactly like clicking the dot; the rail scrolls the
262
- * selected dot into view only when it is clipped by the band's edges.
246
+ * jumps to its question, exactly like clicking the dot. Hovering never scrolls
247
+ * the band the dot column stays put while you browse.
263
248
  *
264
249
  * Overflow is paged, not auto-scrolled: two small triangle buttons in the dot
265
- * style sit above and below the dot queue (▲ / ▼), each click revealing five
266
- * hidden dots. The native scrollbar stays hidden and the column fades at its
267
- * edges as a pure visual cue no hover auto-scroll.
250
+ * style sit above and below the dot queue (▲ / ▼), each click revealing one
251
+ * page of hidden dots (the page size is configurable in the plugin settings,
252
+ * default 5) with a staggered pop-in animation as click feedback. The native
253
+ * scrollbar stays hidden; the triangles themselves are the overflow cue (each
254
+ * appears only while its direction has more to reveal) — no hover auto-scroll
255
+ * of any kind.
268
256
  *
269
257
  * Data source: the host-folded `questionIndex` session projection (whole
270
258
  * history, persisted host-side, pushed live through session/projection
@@ -314,17 +302,19 @@ window.__ModuleLoader__.load({
314
302
  const [hint, setHint] = (0, react.useState)(null);
315
303
  const [focus, setFocus] = (0, react.useState)(null);
316
304
  const [align, setAlign] = (0, react.useState)(() => props.align());
305
+ const [pageSize, setPageSize] = (0, react.useState)(() => props.pageSize());
317
306
  const panelRef = (0, react.useRef)(null);
318
307
  const listRef = (0, react.useRef)(null);
319
308
  const hintTimerRef = (0, react.useRef)(null);
320
309
  const clearFocusTimerRef = (0, react.useRef)(null);
321
310
  const lastDotsRef = (0, react.useRef)([]);
322
- const lastFocusedKeyRef = (0, react.useRef)(null);
323
311
  const [scrollable, setScrollable] = (0, react.useState)(false);
324
312
  const [scrollPos, setScrollPos] = (0, react.useState)({
325
313
  top: 0,
326
314
  max: 0
327
315
  });
316
+ const [revealed, setRevealed] = (0, react.useState)(null);
317
+ const revealTimerRef = (0, react.useRef)(null);
328
318
  const syncScroll = () => {
329
319
  const list = listRef.current;
330
320
  if (list === null) return;
@@ -338,8 +328,33 @@ window.__ModuleLoader__.load({
338
328
  const list = listRef.current;
339
329
  if (list === null) return;
340
330
  const max = Math.max(0, list.scrollHeight - list.clientHeight);
341
- list.scrollTop = clampScrollTop(list.scrollTop + dir * pageStep(), max);
331
+ const target = clampScrollTop(list.scrollTop + dir * pageStep(8, 6, pageSize), max);
332
+ if (target === list.scrollTop) return;
333
+ const band = list.getBoundingClientRect();
334
+ const els = Array.from(list.querySelectorAll("[data-question-nav-key]"));
335
+ const wasVisible = els.map((el) => {
336
+ const r = el.getBoundingClientRect();
337
+ return r.bottom > band.top && r.top < band.bottom;
338
+ });
339
+ list.scrollTop = target;
342
340
  syncScroll();
341
+ const next = /* @__PURE__ */ new Map();
342
+ const fresh = [];
343
+ els.forEach((el, i) => {
344
+ if (wasVisible[i]) return;
345
+ const r = el.getBoundingClientRect();
346
+ const key = el.dataset.questionNavKey;
347
+ if (key !== void 0 && r.bottom > band.top && r.top < band.bottom) fresh.push(key);
348
+ });
349
+ if (dir === 1) fresh.reverse();
350
+ fresh.forEach((key, order) => next.set(key, {
351
+ dir,
352
+ order
353
+ }));
354
+ if (next.size === 0) return;
355
+ setRevealed(next);
356
+ if (revealTimerRef.current !== null) window.clearTimeout(revealTimerRef.current);
357
+ revealTimerRef.current = window.setTimeout(() => setRevealed(null), 320 + next.size * 35 + 120);
343
358
  };
344
359
  const canPageUp = scrollable && scrollPos.top > 0;
345
360
  const canPageDown = scrollable && scrollPos.top < scrollPos.max;
@@ -353,7 +368,10 @@ window.__ModuleLoader__.load({
353
368
  cancelClearFocus();
354
369
  clearFocusTimerRef.current = window.setTimeout(() => setFocus(null), 240);
355
370
  };
356
- (0, react.useEffect)(() => props.subscribeAlign(() => setAlign(props.align())), [props]);
371
+ (0, react.useEffect)(() => props.subscribeSettings(() => {
372
+ setAlign(props.align());
373
+ setPageSize(props.pageSize());
374
+ }), [props]);
357
375
  const showHint = (message) => {
358
376
  setHint(message);
359
377
  if (hintTimerRef.current !== null) window.clearTimeout(hintTimerRef.current);
@@ -477,19 +495,6 @@ window.__ModuleLoader__.load({
477
495
  window.removeEventListener("resize", wake);
478
496
  };
479
497
  }, [visible, align]);
480
- (0, react.useLayoutEffect)(() => {
481
- const key = focus?.key ?? null;
482
- if (lastFocusedKeyRef.current === key) return;
483
- lastFocusedKeyRef.current = key;
484
- if (key === null) return;
485
- const list = listRef.current;
486
- if (list === null) return;
487
- const target = list.querySelector("[data-question-nav-focused=\"true\"]");
488
- if (target === null) return;
489
- const next = minimalScrollIntoView(list.scrollTop, list.clientHeight, list.scrollHeight, target.offsetTop - list.offsetTop, target.offsetHeight);
490
- if (next !== null) list.scrollTop = next;
491
- syncScroll();
492
- }, [focus]);
493
498
  (0, react.useLayoutEffect)(() => {
494
499
  const list = listRef.current;
495
500
  if (list === null) return;
@@ -506,6 +511,7 @@ window.__ModuleLoader__.load({
506
511
  (0, react.useEffect)(() => () => {
507
512
  if (hintTimerRef.current !== null) window.clearTimeout(hintTimerRef.current);
508
513
  if (clearFocusTimerRef.current !== null) window.clearTimeout(clearFocusTimerRef.current);
514
+ if (revealTimerRef.current !== null) window.clearTimeout(revealTimerRef.current);
509
515
  }, []);
510
516
  if (!visible) return null;
511
517
  const onJump = (dot) => {
@@ -562,7 +568,7 @@ window.__ModuleLoader__.load({
562
568
  }) : null,
563
569
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
564
570
  ref: listRef,
565
- className: scrollable ? `${question_nav_module_css_default.list} ${question_nav_module_css_default.listScrollable}` : question_nav_module_css_default.list,
571
+ className: question_nav_module_css_default.list,
566
572
  onScroll: syncScroll,
567
573
  onMouseLeave: scheduleClearFocus,
568
574
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
@@ -571,14 +577,20 @@ window.__ModuleLoader__.load({
571
577
  const isFocused = focus !== null && focus.key === dot.key;
572
578
  const tier = selectedIndex < 0 ? null : focusTier(index - selectedIndex);
573
579
  const scale = jumpingKey === dot.key ? 1.6 : tier !== null ? focusScale(tier) : 1;
580
+ const reveal = revealed?.get(dot.key);
574
581
  const cls = [question_nav_module_css_default.dot];
575
582
  if (isFocused) cls.push(question_nav_module_css_default.focused);
576
583
  if (jumpingKey === dot.key) cls.push(question_nav_module_css_default.active);
584
+ if (reveal !== void 0) cls.push(reveal.dir === 1 ? question_nav_module_css_default.dotEnterFromBottom : question_nav_module_css_default.dotEnterFromTop);
577
585
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
578
586
  className: cls.join(" "),
579
- style: { transform: `scale(${scale})` },
587
+ style: {
588
+ transform: `scale(${scale})`,
589
+ ...reveal !== void 0 ? { animationDelay: `${reveal.order * 35}ms` } : {}
590
+ },
580
591
  "data-question-nav-focused": isFocused ? "true" : void 0,
581
592
  "data-question-nav-index": index,
593
+ "data-question-nav-key": dot.key,
582
594
  "aria-label": dot.texts[0] ?? "",
583
595
  onMouseEnter: (e) => openFocus(dot, e.currentTarget),
584
596
  onClick: () => onJump(dot)
@@ -676,24 +688,45 @@ window.__ModuleLoader__.load({
676
688
  /** Field carrying the selected anchor edge. */
677
689
  const ALIGN_FIELD = "align";
678
690
  //#endregion
691
+ //#region src/core/page-size.ts
692
+ /**
693
+ * Page-size constants for the ▲/▼ paging buttons, shared by the host schema
694
+ * and the browser settings scope. Pure data: no DSH imports, so the client
695
+ * bundle may inline this module (a Host import here would leak into the
696
+ * browser half).
697
+ *
698
+ * @module dsh-question-nav/page-size
699
+ */
700
+ /** Selectable page sizes: how many hidden dots one ▲/▼ click reveals. */
701
+ const PAGE_SIZE_OPTIONS = [
702
+ 3,
703
+ 5,
704
+ 8,
705
+ 10
706
+ ];
707
+ /** Field carrying the selected page size. */
708
+ const PAGE_SIZE_FIELD = "pageSize";
709
+ //#endregion
679
710
  //#region src/client/QuestionNavSettingsTab.tsx
680
711
  /**
681
712
  * The plugin's settings page inside the shell's Plugins section
682
- * (`settings.plugins.tab`): a segmented control choosing which edge of the
683
- * conversation column the dot rail anchors to. The choice is written to the
713
+ * (`settings.plugins.tab`): two segmented controls which edge of the
714
+ * conversation column the dot rail anchors to, and how many hidden dots one
715
+ * ▲/▼ paging-button click reveals. The choices are written to the
684
716
  * `question-nav` settings namespace (registered by the host half); the strip
685
- * re-anchors live when the snapshot changes.
717
+ * re-anchors and re-steps live when the snapshot changes.
686
718
  *
687
719
  * @module dsh-question-nav/client/settings-tab
688
720
  */
689
721
  /** Re-render on settings snapshot changes (the register inject face is static). */
690
- function useAlignTick(subscribe) {
722
+ function useSettingsTick(subscribe) {
691
723
  const [, bump] = (0, react.useState)(0);
692
724
  (0, react.useEffect)(() => subscribe(() => bump((n) => n + 1)), [subscribe]);
693
725
  }
694
726
  function QuestionNavSettingsTab(props) {
695
- useAlignTick(props.subscribeAlign);
727
+ useSettingsTick(props.subscribeSettings);
696
728
  const align = props.align();
729
+ const pageSize = props.pageSize();
697
730
  const t = props.t;
698
731
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
699
732
  className: question_nav_module_css_default.settings,
@@ -718,16 +751,41 @@ window.__ModuleLoader__.load({
718
751
  onClick: () => props.setAlign(option),
719
752
  children: t(option === "left" ? "settings.align.left" : "settings.align.right")
720
753
  }, option))
754
+ }),
755
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
756
+ className: question_nav_module_css_default.settingsTitle,
757
+ children: t("settings.pagesize.title")
758
+ }),
759
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
760
+ className: question_nav_module_css_default.settingsDesc,
761
+ children: t("settings.pagesize.desc")
762
+ }),
763
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
764
+ className: question_nav_module_css_default.segmented,
765
+ role: "radiogroup",
766
+ "aria-label": t("settings.pagesize.title"),
767
+ children: PAGE_SIZE_OPTIONS.map((option) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
768
+ type: "button",
769
+ role: "radio",
770
+ "aria-checked": pageSize === option,
771
+ className: pageSize === option ? `${question_nav_module_css_default.segment} ${question_nav_module_css_default.segmentActive}` : question_nav_module_css_default.segment,
772
+ onClick: () => props.setPageSize(option),
773
+ children: option
774
+ }, option))
721
775
  })
722
776
  ]
723
777
  });
724
778
  }
725
779
  //#endregion
726
780
  //#region src/client/settings.ts
727
- /** Narrow a raw section to the anchor field. */
781
+ /** Narrow a raw section value to the anchor field. */
728
782
  function isAlignPreference(value) {
729
783
  return ALIGN_OPTIONS.some((option) => option === value);
730
784
  }
785
+ /** Narrow a raw section value to the page-size field. */
786
+ function isPageSize(value) {
787
+ return PAGE_SIZE_OPTIONS.includes(value);
788
+ }
731
789
  /** Reactive handle over the plugin's durable settings section. */
732
790
  var QuestionNavSettingsController = class {
733
791
  scope;
@@ -735,6 +793,7 @@ window.__ModuleLoader__.load({
735
793
  unsubscribe = () => {};
736
794
  state = {
737
795
  align: DEFAULT_ALIGN,
796
+ pageSize: 5,
738
797
  overridden: false
739
798
  };
740
799
  /**
@@ -751,7 +810,7 @@ window.__ModuleLoader__.load({
751
810
  this.unsubscribe = this.scope.subscribe(() => {
752
811
  if (this.scope === void 0) return;
753
812
  const next = this.derive(this.scope.getSnapshot());
754
- if (next.align === this.state.align && next.overridden === this.state.overridden) return;
813
+ if (next.align === this.state.align && next.pageSize === this.state.pageSize && next.overridden === this.state.overridden) return;
755
814
  this.state = next;
756
815
  for (const listener of this.listeners) listener();
757
816
  });
@@ -760,7 +819,8 @@ window.__ModuleLoader__.load({
760
819
  const user = snapshot.user;
761
820
  return {
762
821
  align: snapshot.status === "ready" && isAlignPreference(snapshot.value?.align) ? snapshot.value.align : DEFAULT_ALIGN,
763
- overridden: user !== void 0 && user.align !== void 0
822
+ pageSize: snapshot.status === "ready" && isPageSize(snapshot.value?.pageSize) ? snapshot.value.pageSize : 5,
823
+ overridden: user !== void 0 && (user.align !== void 0 || user.pageSize !== void 0)
764
824
  };
765
825
  }
766
826
  /** Release the scope subscription (bound on the settings fiber's lifecycle). */
@@ -784,6 +844,11 @@ window.__ModuleLoader__.load({
784
844
  if (this.scope === void 0) return;
785
845
  this.scope.set(ALIGN_FIELD, align);
786
846
  }
847
+ /** Route the user's page-size choice to the Host document. */
848
+ setPageSize(pageSize) {
849
+ if (this.scope === void 0) return;
850
+ this.scope.set(PAGE_SIZE_FIELD, pageSize);
851
+ }
787
852
  };
788
853
  //#endregion
789
854
  //#region src/client/locales.ts
@@ -793,8 +858,8 @@ window.__ModuleLoader__.load({
793
858
  */
794
859
  const zh = {
795
860
  "strip.empty": "本会话还没有提问",
796
- "strip.up": "向上翻出 5 个提问圆点",
797
- "strip.down": "向下翻出 5 个提问圆点",
861
+ "strip.up": "向上翻出更多提问圆点",
862
+ "strip.down": "向下翻出更多提问圆点",
798
863
  "jump.inactive": "聊天视图未激活",
799
864
  "jump.hidden": "目标无独立气泡,已定位到邻近内容",
800
865
  "jump.notfound": "目标未加载或不存在(可能已压缩)",
@@ -803,12 +868,14 @@ window.__ModuleLoader__.load({
803
868
  "settings.align.title": "导航条对齐",
804
869
  "settings.align.desc": "选择圆点导航条锚定在对话栏的哪一侧。",
805
870
  "settings.align.left": "左侧",
806
- "settings.align.right": "右侧"
871
+ "settings.align.right": "右侧",
872
+ "settings.pagesize.title": "每次翻出数量",
873
+ "settings.pagesize.desc": "点击 ▲/▼ 三角按钮时,每次翻出的隐藏圆点数量。"
807
874
  };
808
875
  const en = {
809
876
  "strip.empty": "No questions in this session yet",
810
- "strip.up": "Reveal 5 question dots above",
811
- "strip.down": "Reveal 5 question dots below",
877
+ "strip.up": "Reveal more question dots above",
878
+ "strip.down": "Reveal more question dots below",
812
879
  "jump.inactive": "Chat view is not active",
813
880
  "jump.hidden": "No dedicated bubble; landed on nearby content",
814
881
  "jump.notfound": "Target not loaded or missing (maybe compacted)",
@@ -817,7 +884,9 @@ window.__ModuleLoader__.load({
817
884
  "settings.align.title": "Rail alignment",
818
885
  "settings.align.desc": "Choose which edge of the conversation column the dot rail anchors to.",
819
886
  "settings.align.left": "Left",
820
- "settings.align.right": "Right"
887
+ "settings.align.right": "Right",
888
+ "settings.pagesize.title": "Dots per page",
889
+ "settings.pagesize.desc": "How many hidden dots one click of the ▲/▼ paging buttons reveals."
821
890
  };
822
891
  //#endregion
823
892
  //#region src/core/nodes.ts
@@ -1047,8 +1116,10 @@ window.__ModuleLoader__.load({
1047
1116
  jumpToQuestion(ports, key);
1048
1117
  },
1049
1118
  align: () => settings.getSnapshot().align,
1050
- subscribeAlign: (cb) => settings.subscribe(cb),
1051
- setAlign: (align) => settings.setAlign(align)
1119
+ subscribeSettings: (cb) => settings.subscribe(cb),
1120
+ setAlign: (align) => settings.setAlign(align),
1121
+ pageSize: () => settings.getSnapshot().pageSize,
1122
+ setPageSize: (pageSize) => settings.setPageSize(pageSize)
1052
1123
  };
1053
1124
  }
1054
1125
  /**