@luziyang2026/dsh-question-nav 0.4.2 → 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 +29 -9
- package/README.zh.md +22 -7
- package/lib/client.js +408 -50
- package/lib/client.js.map +1 -1
- package/lib/index.js +832 -4
- package/lib/types/client/QuestionNavSettingsTab.d.ts +14 -0
- package/lib/types/client/QuestionNavStrip.d.ts +7 -0
- package/lib/types/client/locales.d.ts +10 -0
- package/lib/types/client/settings.d.ts +52 -0
- package/lib/types/core/align.d.ts +18 -0
- package/lib/types/core/focus.d.ts +44 -0
- package/lib/types/core/time.d.ts +19 -0
- package/lib/types/index.d.ts +5 -4
- package/lib/types/settings.d.ts +24 -0
- package/package.json +7 -3
- package/src/client/QuestionNavSettingsTab.tsx +53 -0
- package/src/client/QuestionNavStrip.tsx +225 -46
- package/src/client/index.ts +28 -2
- package/src/client/locales.ts +10 -0
- package/src/client/question-nav.module.css +147 -16
- package/src/client/settings.ts +105 -0
- package/src/core/align.ts +23 -0
- package/src/core/focus.ts +76 -0
- package/src/core/time.ts +39 -0
- package/src/index.ts +9 -4
- package/src/settings.ts +33 -0
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_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,27 +192,47 @@ 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
|
-
"
|
|
121
|
-
"
|
|
122
|
-
"
|
|
211
|
+
"railRight": "TWf_pa_railRight",
|
|
212
|
+
"segment": "TWf_pa_segment",
|
|
213
|
+
"segmentActive": "TWf_pa_segmentActive",
|
|
214
|
+
"segmented": "TWf_pa_segmented",
|
|
215
|
+
"settings": "TWf_pa_settings",
|
|
216
|
+
"settingsDesc": "TWf_pa_settingsDesc",
|
|
217
|
+
"settingsTitle": "TWf_pa_settingsTitle"
|
|
123
218
|
};
|
|
124
219
|
//#endregion
|
|
125
220
|
//#region src/client/QuestionNavStrip.tsx
|
|
126
221
|
/**
|
|
127
222
|
* Question-nav minimap. Renders a vertical column of small round dots overlaid
|
|
128
223
|
* on the LEFT edge of the conversation column (via the frame-wide
|
|
129
|
-
* `shell.overlay` floating layer)
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
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.
|
|
135
236
|
*
|
|
136
237
|
* Data source: the host-folded `questionIndex` session projection (whole
|
|
137
238
|
* history, persisted host-side, pushed live through session/projection
|
|
@@ -160,8 +261,8 @@ window.__ModuleLoader__.load({
|
|
|
160
261
|
return document.querySelector("[data-slot=\"conversation\"] > div[data-phase]");
|
|
161
262
|
}
|
|
162
263
|
/** Structural equality of two dot lists (member keys fully capture a dot's
|
|
163
|
-
*
|
|
164
|
-
*
|
|
264
|
+
* folded questions, so identical key sequences mean identical content).
|
|
265
|
+
* Lets the strip skip a re-render when a refresh produced no change. */
|
|
165
266
|
function sameDots(a, b) {
|
|
166
267
|
if (a.length !== b.length) return false;
|
|
167
268
|
for (let i = 0; i < a.length; i++) {
|
|
@@ -179,10 +280,25 @@ window.__ModuleLoader__.load({
|
|
|
179
280
|
const [dots, setDots] = (0, react.useState)([]);
|
|
180
281
|
const [jumpingKey, setJumpingKey] = (0, react.useState)(null);
|
|
181
282
|
const [hint, setHint] = (0, react.useState)(null);
|
|
182
|
-
const [
|
|
283
|
+
const [focus, setFocus] = (0, react.useState)(null);
|
|
284
|
+
const [align, setAlign] = (0, react.useState)(() => props.align());
|
|
183
285
|
const panelRef = (0, react.useRef)(null);
|
|
286
|
+
const listRef = (0, react.useRef)(null);
|
|
184
287
|
const hintTimerRef = (0, react.useRef)(null);
|
|
288
|
+
const clearFocusTimerRef = (0, react.useRef)(null);
|
|
185
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
|
+
};
|
|
301
|
+
(0, react.useEffect)(() => props.subscribeAlign(() => setAlign(props.align())), [props]);
|
|
186
302
|
const showHint = (message) => {
|
|
187
303
|
setHint(message);
|
|
188
304
|
if (hintTimerRef.current !== null) window.clearTimeout(hintTimerRef.current);
|
|
@@ -256,11 +372,21 @@ window.__ModuleLoader__.load({
|
|
|
256
372
|
if (convRect.height <= 0 || convRect.width <= 0) return true;
|
|
257
373
|
const top = `${convRect.top - frameRect.top}px`;
|
|
258
374
|
const height = `${convRect.height}px`;
|
|
375
|
+
if (align === "right") {
|
|
376
|
+
const right = `${frameRect.right - convRect.right}px`;
|
|
377
|
+
if (panel.style.top === top && panel.style.height === height && panel.style.right === right && panel.style.left === "") return false;
|
|
378
|
+
panel.style.top = top;
|
|
379
|
+
panel.style.height = height;
|
|
380
|
+
panel.style.right = right;
|
|
381
|
+
panel.style.left = "";
|
|
382
|
+
return true;
|
|
383
|
+
}
|
|
259
384
|
const left = `${convRect.left - frameRect.left}px`;
|
|
260
|
-
if (panel.style.top === top && panel.style.height === height && panel.style.left === left) return false;
|
|
385
|
+
if (panel.style.top === top && panel.style.height === height && panel.style.left === left && panel.style.right === "") return false;
|
|
261
386
|
panel.style.top = top;
|
|
262
387
|
panel.style.height = height;
|
|
263
388
|
panel.style.left = left;
|
|
389
|
+
panel.style.right = "";
|
|
264
390
|
return true;
|
|
265
391
|
};
|
|
266
392
|
const loop = () => {
|
|
@@ -295,9 +421,22 @@ window.__ModuleLoader__.load({
|
|
|
295
421
|
observer?.disconnect();
|
|
296
422
|
window.removeEventListener("resize", wake);
|
|
297
423
|
};
|
|
298
|
-
}, [visible]);
|
|
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]);
|
|
299
437
|
(0, react.useEffect)(() => () => {
|
|
300
438
|
if (hintTimerRef.current !== null) window.clearTimeout(hintTimerRef.current);
|
|
439
|
+
if (clearFocusTimerRef.current !== null) window.clearTimeout(clearFocusTimerRef.current);
|
|
301
440
|
}, []);
|
|
302
441
|
if (!visible) return null;
|
|
303
442
|
const onJump = (dot) => {
|
|
@@ -306,28 +445,37 @@ window.__ModuleLoader__.load({
|
|
|
306
445
|
props.jump(current, dot.key);
|
|
307
446
|
window.setTimeout(() => setJumpingKey((k) => k === dot.key ? null : k), 600);
|
|
308
447
|
};
|
|
309
|
-
const
|
|
448
|
+
const openFocus = (dot, target) => {
|
|
449
|
+
const index = dots.findIndex((d) => d.key === dot.key);
|
|
450
|
+
if (index < 0) return;
|
|
451
|
+
cancelClearFocus();
|
|
310
452
|
const r = target.getBoundingClientRect();
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
|
316
466
|
});
|
|
317
467
|
};
|
|
318
468
|
const t = props.t;
|
|
469
|
+
const selectedIndex = focus === null ? -1 : dots.findIndex((d) => d.key === focus.key);
|
|
319
470
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
320
471
|
ref: panelRef,
|
|
321
|
-
className: question_nav_module_css_default.rail,
|
|
472
|
+
className: align === "right" ? `${question_nav_module_css_default.rail} ${question_nav_module_css_default.railRight}` : question_nav_module_css_default.rail,
|
|
322
473
|
"data-question-nav": "rail",
|
|
323
474
|
children: [
|
|
324
|
-
hint !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
325
|
-
className: question_nav_module_css_default.hint,
|
|
326
|
-
role: "status",
|
|
327
|
-
children: hint
|
|
328
|
-
}) : null,
|
|
329
475
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
476
|
+
ref: listRef,
|
|
330
477
|
className: question_nav_module_css_default.list,
|
|
478
|
+
onMouseLeave: scheduleClearFocus,
|
|
331
479
|
children: dots.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
332
480
|
className: question_nav_module_css_default.empty,
|
|
333
481
|
children: t("strip.empty")
|
|
@@ -336,33 +484,217 @@ window.__ModuleLoader__.load({
|
|
|
336
484
|
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
337
485
|
className: question_nav_module_css_default.count,
|
|
338
486
|
children: dots.length
|
|
339
|
-
}), dots.map((dot) =>
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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
|
+
})]
|
|
346
504
|
})
|
|
347
505
|
}),
|
|
348
|
-
|
|
349
|
-
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,
|
|
350
519
|
style: {
|
|
351
|
-
left:
|
|
352
|
-
|
|
520
|
+
...focus.left !== void 0 ? { left: focus.left } : {},
|
|
521
|
+
...focus.right !== void 0 ? { right: focus.right } : {},
|
|
522
|
+
top: focus.centerY,
|
|
523
|
+
transform: "translateY(-50%)"
|
|
353
524
|
},
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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
|
+
})
|
|
361
565
|
}), document.body) : null
|
|
362
566
|
]
|
|
363
567
|
});
|
|
364
568
|
}
|
|
365
569
|
//#endregion
|
|
570
|
+
//#region src/core/align.ts
|
|
571
|
+
/**
|
|
572
|
+
* Rail anchor-edge constants shared by the host schema and the browser
|
|
573
|
+
* settings scope. Pure data: no DSH imports, so the client bundle may inline
|
|
574
|
+
* this module (a Host import here would leak into the browser half).
|
|
575
|
+
*
|
|
576
|
+
* @module dsh-question-nav/align
|
|
577
|
+
*/
|
|
578
|
+
/** Supported rail anchor edges. */
|
|
579
|
+
const ALIGN_OPTIONS = ["left", "right"];
|
|
580
|
+
/** Default anchor edge when the user-settings document has no override. */
|
|
581
|
+
const DEFAULT_ALIGN = "left";
|
|
582
|
+
/** Settings namespace owned by this plugin (spelled here rather than
|
|
583
|
+
* imported: the client bundle must not depend on a Host package). */
|
|
584
|
+
const QUESTION_NAV_SETTINGS_NS = "question-nav";
|
|
585
|
+
/** Field carrying the selected anchor edge. */
|
|
586
|
+
const ALIGN_FIELD = "align";
|
|
587
|
+
//#endregion
|
|
588
|
+
//#region src/client/QuestionNavSettingsTab.tsx
|
|
589
|
+
/**
|
|
590
|
+
* The plugin's settings page inside the shell's Plugins section
|
|
591
|
+
* (`settings.plugins.tab`): a segmented control choosing which edge of the
|
|
592
|
+
* conversation column the dot rail anchors to. The choice is written to the
|
|
593
|
+
* `question-nav` settings namespace (registered by the host half); the strip
|
|
594
|
+
* re-anchors live when the snapshot changes.
|
|
595
|
+
*
|
|
596
|
+
* @module dsh-question-nav/client/settings-tab
|
|
597
|
+
*/
|
|
598
|
+
/** Re-render on settings snapshot changes (the register inject face is static). */
|
|
599
|
+
function useAlignTick(subscribe) {
|
|
600
|
+
const [, bump] = (0, react.useState)(0);
|
|
601
|
+
(0, react.useEffect)(() => subscribe(() => bump((n) => n + 1)), [subscribe]);
|
|
602
|
+
}
|
|
603
|
+
function QuestionNavSettingsTab(props) {
|
|
604
|
+
useAlignTick(props.subscribeAlign);
|
|
605
|
+
const align = props.align();
|
|
606
|
+
const t = props.t;
|
|
607
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
608
|
+
className: question_nav_module_css_default.settings,
|
|
609
|
+
children: [
|
|
610
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
611
|
+
className: question_nav_module_css_default.settingsTitle,
|
|
612
|
+
children: t("settings.align.title")
|
|
613
|
+
}),
|
|
614
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
615
|
+
className: question_nav_module_css_default.settingsDesc,
|
|
616
|
+
children: t("settings.align.desc")
|
|
617
|
+
}),
|
|
618
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
619
|
+
className: question_nav_module_css_default.segmented,
|
|
620
|
+
role: "radiogroup",
|
|
621
|
+
"aria-label": t("settings.align.title"),
|
|
622
|
+
children: ALIGN_OPTIONS.map((option) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
623
|
+
type: "button",
|
|
624
|
+
role: "radio",
|
|
625
|
+
"aria-checked": align === option,
|
|
626
|
+
className: align === option ? `${question_nav_module_css_default.segment} ${question_nav_module_css_default.segmentActive}` : question_nav_module_css_default.segment,
|
|
627
|
+
onClick: () => props.setAlign(option),
|
|
628
|
+
children: t(option === "left" ? "settings.align.left" : "settings.align.right")
|
|
629
|
+
}, option))
|
|
630
|
+
})
|
|
631
|
+
]
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
//#endregion
|
|
635
|
+
//#region src/client/settings.ts
|
|
636
|
+
/** Narrow a raw section to the anchor field. */
|
|
637
|
+
function isAlignPreference(value) {
|
|
638
|
+
return ALIGN_OPTIONS.some((option) => option === value);
|
|
639
|
+
}
|
|
640
|
+
/** Reactive handle over the plugin's durable settings section. */
|
|
641
|
+
var QuestionNavSettingsController = class {
|
|
642
|
+
scope;
|
|
643
|
+
listeners = /* @__PURE__ */ new Set();
|
|
644
|
+
unsubscribe = () => {};
|
|
645
|
+
state = {
|
|
646
|
+
align: DEFAULT_ALIGN,
|
|
647
|
+
overridden: false
|
|
648
|
+
};
|
|
649
|
+
/**
|
|
650
|
+
* Bind the namespace scope once the settings surface is present. Called
|
|
651
|
+
* from a fiber that injects `settingsScope`, so the scope subscription
|
|
652
|
+
* lives on that fiber and is released with it. A no-op after the first
|
|
653
|
+
* bind.
|
|
654
|
+
* @param binder - the settings scope service.
|
|
655
|
+
*/
|
|
656
|
+
attach(binder) {
|
|
657
|
+
if (this.scope !== void 0) return;
|
|
658
|
+
this.scope = binder.bind({ namespace: QUESTION_NAV_SETTINGS_NS });
|
|
659
|
+
this.state = this.derive(this.scope.getSnapshot());
|
|
660
|
+
this.unsubscribe = this.scope.subscribe(() => {
|
|
661
|
+
if (this.scope === void 0) return;
|
|
662
|
+
const next = this.derive(this.scope.getSnapshot());
|
|
663
|
+
if (next.align === this.state.align && next.overridden === this.state.overridden) return;
|
|
664
|
+
this.state = next;
|
|
665
|
+
for (const listener of this.listeners) listener();
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
derive(snapshot) {
|
|
669
|
+
const user = snapshot.user;
|
|
670
|
+
return {
|
|
671
|
+
align: snapshot.status === "ready" && isAlignPreference(snapshot.value?.align) ? snapshot.value.align : DEFAULT_ALIGN,
|
|
672
|
+
overridden: user !== void 0 && user.align !== void 0
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
/** Release the scope subscription (bound on the settings fiber's lifecycle). */
|
|
676
|
+
dispose() {
|
|
677
|
+
this.unsubscribe();
|
|
678
|
+
this.listeners.clear();
|
|
679
|
+
}
|
|
680
|
+
/** @returns the current state (stable reference until the next change). */
|
|
681
|
+
getSnapshot() {
|
|
682
|
+
return this.state;
|
|
683
|
+
}
|
|
684
|
+
/** Observe state replacements; returns the disposer. */
|
|
685
|
+
subscribe(listener) {
|
|
686
|
+
this.listeners.add(listener);
|
|
687
|
+
return () => {
|
|
688
|
+
this.listeners.delete(listener);
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
/** Route the user's anchor-edge choice to the Host document. */
|
|
692
|
+
setAlign(align) {
|
|
693
|
+
if (this.scope === void 0) return;
|
|
694
|
+
this.scope.set(ALIGN_FIELD, align);
|
|
695
|
+
}
|
|
696
|
+
};
|
|
697
|
+
//#endregion
|
|
366
698
|
//#region src/client/locales.ts
|
|
367
699
|
/**
|
|
368
700
|
* Locale dictionaries for the question-nav surface (zh/en). Registered under
|
|
@@ -373,14 +705,24 @@ window.__ModuleLoader__.load({
|
|
|
373
705
|
"jump.inactive": "聊天视图未激活",
|
|
374
706
|
"jump.hidden": "目标无独立气泡,已定位到邻近内容",
|
|
375
707
|
"jump.notfound": "目标未加载或不存在(可能已压缩)",
|
|
376
|
-
"jump.timeout": "加载历史超时,可重试"
|
|
708
|
+
"jump.timeout": "加载历史超时,可重试",
|
|
709
|
+
"settings.tab": "提问导航",
|
|
710
|
+
"settings.align.title": "导航条对齐",
|
|
711
|
+
"settings.align.desc": "选择圆点导航条锚定在对话栏的哪一侧。",
|
|
712
|
+
"settings.align.left": "左侧",
|
|
713
|
+
"settings.align.right": "右侧"
|
|
377
714
|
};
|
|
378
715
|
const en = {
|
|
379
716
|
"strip.empty": "No questions in this session yet",
|
|
380
717
|
"jump.inactive": "Chat view is not active",
|
|
381
718
|
"jump.hidden": "No dedicated bubble; landed on nearby content",
|
|
382
719
|
"jump.notfound": "Target not loaded or missing (maybe compacted)",
|
|
383
|
-
"jump.timeout": "Timed out loading history; retry"
|
|
720
|
+
"jump.timeout": "Timed out loading history; retry",
|
|
721
|
+
"settings.tab": "Question Nav",
|
|
722
|
+
"settings.align.title": "Rail alignment",
|
|
723
|
+
"settings.align.desc": "Choose which edge of the conversation column the dot rail anchors to.",
|
|
724
|
+
"settings.align.left": "Left",
|
|
725
|
+
"settings.align.right": "Right"
|
|
384
726
|
};
|
|
385
727
|
//#endregion
|
|
386
728
|
//#region src/core/nodes.ts
|
|
@@ -588,7 +930,7 @@ window.__ModuleLoader__.load({
|
|
|
588
930
|
subscribe: (listener) => face.subscribe(listener)
|
|
589
931
|
};
|
|
590
932
|
}
|
|
591
|
-
function createInject(ctx) {
|
|
933
|
+
function createInject(ctx, settings) {
|
|
592
934
|
return {
|
|
593
935
|
readQuestions: (sessionId) => {
|
|
594
936
|
const snap = ctx.sessions.binding(sessionId)?.session.getSnapshot();
|
|
@@ -608,7 +950,10 @@ window.__ModuleLoader__.load({
|
|
|
608
950
|
window.dispatchEvent(new CustomEvent("question-nav:jump-failed", { detail: code }));
|
|
609
951
|
};
|
|
610
952
|
jumpToQuestion(ports, key);
|
|
611
|
-
}
|
|
953
|
+
},
|
|
954
|
+
align: () => settings.getSnapshot().align,
|
|
955
|
+
subscribeAlign: (cb) => settings.subscribe(cb),
|
|
956
|
+
setAlign: (align) => settings.setAlign(align)
|
|
612
957
|
};
|
|
613
958
|
}
|
|
614
959
|
/**
|
|
@@ -622,7 +967,9 @@ window.__ModuleLoader__.load({
|
|
|
622
967
|
zh,
|
|
623
968
|
en
|
|
624
969
|
}), "question-nav: dictionaries");
|
|
625
|
-
const
|
|
970
|
+
const t = ctx.locale.bind(NS);
|
|
971
|
+
const settings = new QuestionNavSettingsController();
|
|
972
|
+
const injected = createInject(ctx, settings);
|
|
626
973
|
ctx.slots.inject("shell.overlay", () => ctx.slots.register({
|
|
627
974
|
name: "shell.overlay",
|
|
628
975
|
id: "question-nav",
|
|
@@ -630,6 +977,17 @@ window.__ModuleLoader__.load({
|
|
|
630
977
|
locale: NS,
|
|
631
978
|
inject: () => injected
|
|
632
979
|
}, QuestionNavStrip));
|
|
980
|
+
ctx.inject(["settingsScope"], (scopeCtx) => {
|
|
981
|
+
settings.attach(scopeCtx.get("settingsScope"));
|
|
982
|
+
ctx.slots.inject("settings.plugins.tab", () => ctx.slots.register({
|
|
983
|
+
name: "settings.plugins.tab",
|
|
984
|
+
id: "question-nav",
|
|
985
|
+
order: 100,
|
|
986
|
+
label: () => t("settings.tab"),
|
|
987
|
+
locale: NS,
|
|
988
|
+
inject: () => injected
|
|
989
|
+
}, QuestionNavSettingsTab));
|
|
990
|
+
});
|
|
633
991
|
}
|
|
634
992
|
//#endregion
|
|
635
993
|
exports.apply = apply;
|