@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
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Question-nav minimap. Renders a vertical column of small round dots overlaid
|
|
3
3
|
* on the LEFT edge of the conversation column (via the frame-wide
|
|
4
|
-
* `shell.overlay` floating layer)
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
4
|
+
* `shell.overlay` floating layer): one dot per turn that claimed at least one
|
|
5
|
+
* user question — strictly aligned with the Trajectory view's turn numbering
|
|
6
|
+
* (turns without a question produce no dot). The dot column is vertically
|
|
7
|
+
* centered and clamped to at most 60% of the conversation height, scrolling
|
|
8
|
+
* within that band when the session has more dots than fit.
|
|
9
|
+
*
|
|
10
|
+
* Hovering a dot "selects" it: the selected dot plus its two immediate
|
|
11
|
+
* neighbors on each side magnify (progressively smaller with distance), the
|
|
12
|
+
* selected dot reveals a frosted question card (turn label, full question
|
|
13
|
+
* text, sent time) with a decorative stack of blurred cards fanning out
|
|
14
|
+
* below it, and the rail auto-centers the selected dot. Clicking jumps the
|
|
15
|
+
* chat to that turn's first question.
|
|
10
16
|
*
|
|
11
17
|
* Data source: the host-folded `questionIndex` session projection (whole
|
|
12
18
|
* history, persisted host-side, pushed live through session/projection
|
|
@@ -28,7 +34,10 @@ import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
|
|
|
28
34
|
import type { QuestionNode } from '../core/nodes.ts'
|
|
29
35
|
import type { QuestionEntry } from '../core/question-entry.ts'
|
|
30
36
|
import { groupQuestionsByTurn, mergeLiveQuestions, type TurnDot } from '../core/turn-dots.ts'
|
|
37
|
+
import type { AlignPreference } from '../core/align.ts'
|
|
31
38
|
import type { JumpFailureCode } from '../core/jump.ts'
|
|
39
|
+
import { FOCUS_RADIUS, focusCardMetrics, focusScale, focusTier } from '../core/focus.ts'
|
|
40
|
+
import { formatQuestionTime } from '../core/time.ts'
|
|
32
41
|
import type { QuestionNavKey } from './locales.ts'
|
|
33
42
|
import styles from './question-nav.module.css'
|
|
34
43
|
|
|
@@ -52,6 +61,12 @@ export interface QuestionNavInjected {
|
|
|
52
61
|
questionProjection: (sessionId: SessionId) => ObservableFace | undefined
|
|
53
62
|
/** Jump the chat to a question row (pages the window on demand). */
|
|
54
63
|
jump: (sessionId: SessionId, key: string) => void
|
|
64
|
+
/** Current rail anchor edge (defaults to 'left' before the settings section is ready). */
|
|
65
|
+
align: () => AlignPreference
|
|
66
|
+
/** Observe anchor-edge changes; returns an unsubscribe. */
|
|
67
|
+
subscribeAlign: (cb: () => void) => () => void
|
|
68
|
+
/** Persist a new anchor edge. */
|
|
69
|
+
setAlign: (align: AlignPreference) => void
|
|
55
70
|
}
|
|
56
71
|
|
|
57
72
|
type ComponentProps = PropsRuntime<'shell.overlay'> & QuestionNavInjected & PropsLocale<'question-nav'>
|
|
@@ -63,14 +78,25 @@ const FAILURE_HINTS: Record<JumpFailureCode, QuestionNavKey> = {
|
|
|
63
78
|
TIMEOUT: 'jump.timeout',
|
|
64
79
|
}
|
|
65
80
|
|
|
66
|
-
/**
|
|
67
|
-
interface
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
81
|
+
/** One card of the focus cascade: a dot inside the magnification window. */
|
|
82
|
+
interface FocusWindowItem {
|
|
83
|
+
dot: TurnDot
|
|
84
|
+
/** Distance from the selected dot (0 = selected, 1/2 = cascade neighbors). */
|
|
85
|
+
distance: number
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The focus cascade: a vertical column of one card per window dot,
|
|
89
|
+
* non-overlapping, centered on the selected dot. */
|
|
90
|
+
interface FocusState {
|
|
91
|
+
/** The selected (hovered) dot's key. */
|
|
92
|
+
key: string
|
|
93
|
+
/** Every dot of the focus window, in dot order. */
|
|
94
|
+
items: FocusWindowItem[]
|
|
95
|
+
/** Vertical center (viewport px) of the selected dot — the column centers on it. */
|
|
96
|
+
centerY: number
|
|
97
|
+
/** Horizontal anchor — left edge (left-aligned rail) or right edge (right-aligned). */
|
|
98
|
+
left?: number
|
|
99
|
+
right?: number
|
|
74
100
|
}
|
|
75
101
|
|
|
76
102
|
/** Read the projection face value as a question-entry list (structural guard). */
|
|
@@ -89,8 +115,8 @@ function findConvRoot(): HTMLElement | null {
|
|
|
89
115
|
}
|
|
90
116
|
|
|
91
117
|
/** Structural equality of two dot lists (member keys fully capture a dot's
|
|
92
|
-
*
|
|
93
|
-
*
|
|
118
|
+
* folded questions, so identical key sequences mean identical content).
|
|
119
|
+
* Lets the strip skip a re-render when a refresh produced no change. */
|
|
94
120
|
function sameDots(a: readonly TurnDot[], b: readonly TurnDot[]): boolean {
|
|
95
121
|
if (a.length !== b.length) return false
|
|
96
122
|
for (let i = 0; i < a.length; i++) {
|
|
@@ -112,11 +138,36 @@ export function QuestionNavStrip(props: ComponentProps): React.JSX.Element | nul
|
|
|
112
138
|
const [dots, setDots] = useState<TurnDot[]>([])
|
|
113
139
|
const [jumpingKey, setJumpingKey] = useState<string | null>(null)
|
|
114
140
|
const [hint, setHint] = useState<string | null>(null)
|
|
115
|
-
const [
|
|
141
|
+
const [focus, setFocus] = useState<FocusState | null>(null)
|
|
142
|
+
const [align, setAlign] = useState<AlignPreference>(() => props.align())
|
|
116
143
|
const panelRef = useRef<HTMLDivElement | null>(null)
|
|
144
|
+
const listRef = useRef<HTMLDivElement | null>(null)
|
|
117
145
|
const hintTimerRef = useRef<number | null>(null)
|
|
146
|
+
// Pending focus-clear timer: lets the mouse move from the rail onto a
|
|
147
|
+
// cascade card without dropping the cascade mid-flight.
|
|
148
|
+
const clearFocusTimerRef = useRef<number | null>(null)
|
|
118
149
|
// Last rendered dot list, for the change-detection bail-out below.
|
|
119
150
|
const lastDotsRef = useRef<TurnDot[]>([])
|
|
151
|
+
// Last focused dot key, so re-hovering the same dot after a gap re-centers it.
|
|
152
|
+
const lastFocusedKeyRef = useRef<string | null>(null)
|
|
153
|
+
|
|
154
|
+
// Focus persists briefly after leaving the rail, so the mouse can reach the
|
|
155
|
+
// clickable cascade cards; entering a card cancels the clear, leaving
|
|
156
|
+
// everything re-arms it.
|
|
157
|
+
const cancelClearFocus = (): void => {
|
|
158
|
+
if (clearFocusTimerRef.current !== null) {
|
|
159
|
+
window.clearTimeout(clearFocusTimerRef.current)
|
|
160
|
+
clearFocusTimerRef.current = null
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
const scheduleClearFocus = (): void => {
|
|
164
|
+
cancelClearFocus()
|
|
165
|
+
clearFocusTimerRef.current = window.setTimeout(() => setFocus(null), 240)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Follow the rail anchor edge from the settings scope (a change re-anchors
|
|
169
|
+
// the rail through the layout effect below).
|
|
170
|
+
useEffect(() => props.subscribeAlign(() => setAlign(props.align())), [props])
|
|
120
171
|
|
|
121
172
|
const showHint = (message: string): void => {
|
|
122
173
|
setHint(message)
|
|
@@ -217,13 +268,29 @@ export function QuestionNavStrip(props: ComponentProps): React.JSX.Element | nul
|
|
|
217
268
|
if (convRect.height <= 0 || convRect.width <= 0) return true
|
|
218
269
|
const top = `${convRect.top - frameRect.top}px`
|
|
219
270
|
const height = `${convRect.height}px`
|
|
271
|
+
// Anchor to the configured edge: keep the other edge's inline style
|
|
272
|
+
// cleared so the CSS class (left: auto on .railRight) owns it.
|
|
273
|
+
if (align === 'right') {
|
|
274
|
+
const right = `${frameRect.right - convRect.right}px`
|
|
275
|
+
if (panel.style.top === top && panel.style.height === height
|
|
276
|
+
&& panel.style.right === right && panel.style.left === '') {
|
|
277
|
+
return false
|
|
278
|
+
}
|
|
279
|
+
panel.style.top = top
|
|
280
|
+
panel.style.height = height
|
|
281
|
+
panel.style.right = right
|
|
282
|
+
panel.style.left = ''
|
|
283
|
+
return true
|
|
284
|
+
}
|
|
220
285
|
const left = `${convRect.left - frameRect.left}px`
|
|
221
|
-
if (panel.style.top === top && panel.style.height === height
|
|
286
|
+
if (panel.style.top === top && panel.style.height === height
|
|
287
|
+
&& panel.style.left === left && panel.style.right === '') {
|
|
222
288
|
return false
|
|
223
289
|
}
|
|
224
290
|
panel.style.top = top
|
|
225
291
|
panel.style.height = height
|
|
226
292
|
panel.style.left = left
|
|
293
|
+
panel.style.right = ''
|
|
227
294
|
return true
|
|
228
295
|
}
|
|
229
296
|
|
|
@@ -257,11 +324,29 @@ export function QuestionNavStrip(props: ComponentProps): React.JSX.Element | nul
|
|
|
257
324
|
observer?.disconnect()
|
|
258
325
|
window.removeEventListener('resize', wake)
|
|
259
326
|
}
|
|
260
|
-
}, [visible])
|
|
327
|
+
}, [visible, align])
|
|
328
|
+
|
|
329
|
+
// Keep the focused dot centered in the (≤60% tall, scrollable) band so its
|
|
330
|
+
// two neighbors on each side stay visible. Re-centers only when the focused
|
|
331
|
+
// key changes; scrolls instantly to avoid chasing a fast-moving hover.
|
|
332
|
+
useLayoutEffect(() => {
|
|
333
|
+
const key = focus?.key ?? null
|
|
334
|
+
if (lastFocusedKeyRef.current === key) return
|
|
335
|
+
lastFocusedKeyRef.current = key
|
|
336
|
+
if (key === null) return
|
|
337
|
+
const list = listRef.current
|
|
338
|
+
if (list === null) return
|
|
339
|
+
const target = list.querySelector<HTMLElement>('[data-question-nav-focused="true"]')
|
|
340
|
+
if (target === null) return
|
|
341
|
+
const delta = target.offsetTop - list.offsetTop
|
|
342
|
+
const center = delta - (list.clientHeight - target.offsetHeight) / 2
|
|
343
|
+
list.scrollTop = Math.max(0, Math.min(center, list.scrollHeight - list.clientHeight))
|
|
344
|
+
}, [focus])
|
|
261
345
|
|
|
262
|
-
// Clear any pending
|
|
346
|
+
// Clear any pending timers on unmount.
|
|
263
347
|
useEffect(() => () => {
|
|
264
348
|
if (hintTimerRef.current !== null) window.clearTimeout(hintTimerRef.current)
|
|
349
|
+
if (clearFocusTimerRef.current !== null) window.clearTimeout(clearFocusTimerRef.current)
|
|
265
350
|
}, [])
|
|
266
351
|
|
|
267
352
|
if (!visible) return null
|
|
@@ -273,47 +358,141 @@ export function QuestionNavStrip(props: ComponentProps): React.JSX.Element | nul
|
|
|
273
358
|
window.setTimeout(() => setJumpingKey((k) => (k === dot.key ? null : k)), 600)
|
|
274
359
|
}
|
|
275
360
|
|
|
276
|
-
const
|
|
361
|
+
const openFocus = (dot: TurnDot, target: HTMLElement): void => {
|
|
362
|
+
const index = dots.findIndex((d) => d.key === dot.key)
|
|
363
|
+
if (index < 0) return
|
|
364
|
+
cancelClearFocus()
|
|
277
365
|
const r = target.getBoundingClientRect()
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
366
|
+
// The cascade opens away from the rail: to the right of the dots on a
|
|
367
|
+
// left-anchored rail, to the left on a right-anchored one. The column is
|
|
368
|
+
// vertically centered on the selected dot (top: centerY + translateY(-50%)).
|
|
369
|
+
const horizontal = align === 'right'
|
|
370
|
+
? { right: window.innerWidth - r.left + 10 }
|
|
371
|
+
: { left: r.right + 10 }
|
|
372
|
+
const lo = Math.max(0, index - FOCUS_RADIUS)
|
|
373
|
+
const hi = Math.min(dots.length - 1, index + FOCUS_RADIUS)
|
|
374
|
+
const items: FocusWindowItem[] = []
|
|
375
|
+
for (let i = lo; i <= hi; i++) {
|
|
376
|
+
items.push({ dot: dots[i], distance: Math.abs(i - index) })
|
|
377
|
+
}
|
|
378
|
+
setFocus({ key: dot.key, items, centerY: r.top + r.height / 2, ...horizontal })
|
|
284
379
|
}
|
|
285
380
|
|
|
286
381
|
const t = props.t
|
|
382
|
+
const selectedIndex = focus === null ? -1 : dots.findIndex((d) => d.key === focus.key)
|
|
287
383
|
|
|
288
384
|
return (
|
|
289
|
-
<div
|
|
290
|
-
|
|
291
|
-
|
|
385
|
+
<div
|
|
386
|
+
ref={panelRef}
|
|
387
|
+
className={align === 'right' ? `${styles.rail} ${styles.railRight}` : styles.rail}
|
|
388
|
+
data-question-nav="rail"
|
|
389
|
+
>
|
|
390
|
+
<div
|
|
391
|
+
ref={listRef}
|
|
392
|
+
className={styles.list}
|
|
393
|
+
onMouseLeave={scheduleClearFocus}
|
|
394
|
+
>
|
|
292
395
|
{dots.length === 0 ? (
|
|
293
396
|
<div className={styles.empty}>{t('strip.empty')}</div>
|
|
294
397
|
) : (
|
|
295
398
|
<div className={styles.dots}>
|
|
296
399
|
<span className={styles.count}>{dots.length}</span>
|
|
297
|
-
{dots.map((dot) =>
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
400
|
+
{dots.map((dot, index) => {
|
|
401
|
+
const isFocused = focus !== null && focus.key === dot.key
|
|
402
|
+
// Progressive magnification: selected largest, ±1 smaller, ±2
|
|
403
|
+
// smaller still, the rest base scale.
|
|
404
|
+
const tier = selectedIndex < 0 ? null : focusTier(index - selectedIndex)
|
|
405
|
+
const scale = jumpingKey === dot.key ? 1.6 : tier !== null ? focusScale(tier) : 1
|
|
406
|
+
const cls = [styles.dot]
|
|
407
|
+
if (isFocused) cls.push(styles.focused)
|
|
408
|
+
if (jumpingKey === dot.key) cls.push(styles.active)
|
|
409
|
+
return (
|
|
410
|
+
<button
|
|
411
|
+
key={dot.key}
|
|
412
|
+
className={cls.join(' ')}
|
|
413
|
+
style={{ transform: `scale(${scale})` }}
|
|
414
|
+
data-question-nav-focused={isFocused ? 'true' : undefined}
|
|
415
|
+
data-question-nav-index={index}
|
|
416
|
+
aria-label={dot.texts[0] ?? ''}
|
|
417
|
+
onMouseEnter={(e) => openFocus(dot, e.currentTarget)}
|
|
418
|
+
onClick={() => onJump(dot)}
|
|
419
|
+
/>
|
|
420
|
+
)
|
|
421
|
+
})}
|
|
307
422
|
</div>
|
|
308
423
|
)}
|
|
309
424
|
</div>
|
|
310
|
-
{
|
|
425
|
+
{hint !== null
|
|
426
|
+
? createPortal(
|
|
427
|
+
<div
|
|
428
|
+
className={styles.hint}
|
|
429
|
+
style={align === 'right' ? { right: 60, top: 20 } : { left: 60, top: 20 }}
|
|
430
|
+
>
|
|
431
|
+
{hint}
|
|
432
|
+
</div>,
|
|
433
|
+
document.body,
|
|
434
|
+
)
|
|
435
|
+
: null}
|
|
436
|
+
{focus !== null
|
|
311
437
|
? createPortal(
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
438
|
+
/* The vertical cascade: one crisp card per window dot, stacked
|
|
439
|
+
non-overlapping, vertically centered on the selected dot.
|
|
440
|
+
Hovering the cascade keeps it alive (the rail only re-arms its
|
|
441
|
+
clear once the mouse leaves both). */
|
|
442
|
+
<div
|
|
443
|
+
className={styles.cascade}
|
|
444
|
+
style={{
|
|
445
|
+
...(focus.left !== undefined ? { left: focus.left } : {}),
|
|
446
|
+
...(focus.right !== undefined ? { right: focus.right } : {}),
|
|
447
|
+
top: focus.centerY,
|
|
448
|
+
transform: 'translateY(-50%)',
|
|
449
|
+
}}
|
|
450
|
+
onMouseEnter={cancelClearFocus}
|
|
451
|
+
onMouseLeave={scheduleClearFocus}
|
|
452
|
+
>
|
|
453
|
+
{focus.items.map((item) => {
|
|
454
|
+
const metrics = focusCardMetrics(item.distance)
|
|
455
|
+
if (metrics === null) return null
|
|
456
|
+
const isSelected = item.distance === 0
|
|
457
|
+
// Cascade cards join their dot's texts into one clamped line;
|
|
458
|
+
// the selected card keeps every line. Every card shows the
|
|
459
|
+
// question's sent time, and clicking any card jumps to its
|
|
460
|
+
// question exactly like clicking the dot.
|
|
461
|
+
const text = item.dot.texts.join(' · ')
|
|
462
|
+
const cls = isSelected ? `${styles.card} ${styles.cardSelected}` : styles.card
|
|
463
|
+
return (
|
|
464
|
+
<div
|
|
465
|
+
key={item.dot.key}
|
|
466
|
+
className={cls}
|
|
467
|
+
role="button"
|
|
468
|
+
tabIndex={-1}
|
|
469
|
+
style={{
|
|
470
|
+
width: metrics.widthPx,
|
|
471
|
+
filter: `brightness(${metrics.brightness})`,
|
|
472
|
+
}}
|
|
473
|
+
onClick={() => onJump(item.dot)}
|
|
474
|
+
>
|
|
475
|
+
{isSelected && item.dot.turn !== null ? (
|
|
476
|
+
<div className={styles.cardTitle}>Turn {item.dot.turn}</div>
|
|
477
|
+
) : null}
|
|
478
|
+
<div
|
|
479
|
+
className={isSelected ? styles.cardBody : styles.cardClamp}
|
|
480
|
+
style={
|
|
481
|
+
isSelected
|
|
482
|
+
? undefined
|
|
483
|
+
: ({ WebkitLineClamp: metrics.maxLines, fontSize: metrics.fontSize } as React.CSSProperties)
|
|
484
|
+
}
|
|
485
|
+
>
|
|
486
|
+
{isSelected
|
|
487
|
+
? item.dot.texts.map((line, lineIndex) => (
|
|
488
|
+
<div key={lineIndex} className={styles.cardLine}>{line}</div>
|
|
489
|
+
))
|
|
490
|
+
: text}
|
|
491
|
+
</div>
|
|
492
|
+
<div className={styles.cardTime}>{formatQuestionTime(item.dot.time, Date.now())}</div>
|
|
493
|
+
</div>
|
|
494
|
+
)
|
|
495
|
+
})}
|
|
317
496
|
</div>,
|
|
318
497
|
document.body,
|
|
319
498
|
)
|
package/src/client/index.ts
CHANGED
|
@@ -23,7 +23,13 @@ import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
|
|
23
23
|
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
|
|
24
24
|
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
|
|
25
25
|
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
|
26
|
+
// Type-only: pulls the settings shell's SlotMap merge ('settings.plugins.tab')
|
|
27
|
+
// and the ctx.settingsScope Context merge.
|
|
28
|
+
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
|
|
29
|
+
import type { SettingsScopeBinder } from '@deepseek-ai/dsh-client-ui-settings/client'
|
|
26
30
|
import { QuestionNavStrip, type ObservableFace, type QuestionNavInjected } from './QuestionNavStrip.tsx'
|
|
31
|
+
import { QuestionNavSettingsTab } from './QuestionNavSettingsTab.tsx'
|
|
32
|
+
import { QuestionNavSettingsController } from './settings.ts'
|
|
27
33
|
import { en, zh, type QuestionNavKey } from './locales.ts'
|
|
28
34
|
import { extractQuestions } from '../core/nodes.ts'
|
|
29
35
|
import { jumpToQuestion, type JumpFailureCode, type JumpPorts } from '../core/jump.ts'
|
|
@@ -103,7 +109,7 @@ function questionProjectionOf(ctx: ClientContext, sessionId: SessionId): Observa
|
|
|
103
109
|
}
|
|
104
110
|
}
|
|
105
111
|
|
|
106
|
-
function createInject(ctx: ClientContext): QuestionNavInjected {
|
|
112
|
+
function createInject(ctx: ClientContext, settings: QuestionNavSettingsController): QuestionNavInjected {
|
|
107
113
|
return {
|
|
108
114
|
readQuestions: (sessionId) => {
|
|
109
115
|
const snap = ctx.sessions.binding(sessionId)?.session.getSnapshot()
|
|
@@ -126,6 +132,9 @@ function createInject(ctx: ClientContext): QuestionNavInjected {
|
|
|
126
132
|
}
|
|
127
133
|
void jumpToQuestion(ports, key)
|
|
128
134
|
},
|
|
135
|
+
align: () => settings.getSnapshot().align,
|
|
136
|
+
subscribeAlign: (cb) => settings.subscribe(cb),
|
|
137
|
+
setAlign: (align) => settings.setAlign(align),
|
|
129
138
|
}
|
|
130
139
|
}
|
|
131
140
|
|
|
@@ -138,8 +147,10 @@ export function apply(ctx: ClientContext): void {
|
|
|
138
147
|
ctx.effect(() => releaseApply, 'question-nav: apply claim')
|
|
139
148
|
|
|
140
149
|
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'question-nav: dictionaries')
|
|
150
|
+
const t = ctx.locale.bind(NS)
|
|
141
151
|
|
|
142
|
-
const
|
|
152
|
+
const settings = new QuestionNavSettingsController()
|
|
153
|
+
const injected = createInject(ctx, settings)
|
|
143
154
|
|
|
144
155
|
ctx.slots.inject('shell.overlay', () => ctx.slots.register({
|
|
145
156
|
name: 'shell.overlay',
|
|
@@ -148,4 +159,19 @@ export function apply(ctx: ClientContext): void {
|
|
|
148
159
|
locale: NS,
|
|
149
160
|
inject: () => injected,
|
|
150
161
|
}, QuestionNavStrip))
|
|
162
|
+
|
|
163
|
+
// The settings surface is optional and may apply after this plugin, so the
|
|
164
|
+
// namespace binds and the settings page mounts in a fiber that waits for
|
|
165
|
+
// it. Without it the strip simply keeps the default left alignment.
|
|
166
|
+
ctx.inject(['settingsScope'], (scopeCtx) => {
|
|
167
|
+
settings.attach(scopeCtx.get('settingsScope') as SettingsScopeBinder)
|
|
168
|
+
ctx.slots.inject('settings.plugins.tab', () => ctx.slots.register({
|
|
169
|
+
name: 'settings.plugins.tab',
|
|
170
|
+
id: 'question-nav',
|
|
171
|
+
order: 100,
|
|
172
|
+
label: () => t('settings.tab'),
|
|
173
|
+
locale: NS,
|
|
174
|
+
inject: () => injected,
|
|
175
|
+
}, QuestionNavSettingsTab))
|
|
176
|
+
})
|
|
151
177
|
}
|
package/src/client/locales.ts
CHANGED
|
@@ -8,6 +8,11 @@ export const zh = {
|
|
|
8
8
|
'jump.hidden': '目标无独立气泡,已定位到邻近内容',
|
|
9
9
|
'jump.notfound': '目标未加载或不存在(可能已压缩)',
|
|
10
10
|
'jump.timeout': '加载历史超时,可重试',
|
|
11
|
+
'settings.tab': '提问导航',
|
|
12
|
+
'settings.align.title': '导航条对齐',
|
|
13
|
+
'settings.align.desc': '选择圆点导航条锚定在对话栏的哪一侧。',
|
|
14
|
+
'settings.align.left': '左侧',
|
|
15
|
+
'settings.align.right': '右侧',
|
|
11
16
|
} as const
|
|
12
17
|
|
|
13
18
|
export const en = {
|
|
@@ -16,6 +21,11 @@ export const en = {
|
|
|
16
21
|
'jump.hidden': 'No dedicated bubble; landed on nearby content',
|
|
17
22
|
'jump.notfound': 'Target not loaded or missing (maybe compacted)',
|
|
18
23
|
'jump.timeout': 'Timed out loading history; retry',
|
|
24
|
+
'settings.tab': 'Question Nav',
|
|
25
|
+
'settings.align.title': 'Rail alignment',
|
|
26
|
+
'settings.align.desc': 'Choose which edge of the conversation column the dot rail anchors to.',
|
|
27
|
+
'settings.align.left': 'Left',
|
|
28
|
+
'settings.align.right': 'Right',
|
|
19
29
|
} as const
|
|
20
30
|
|
|
21
31
|
export type QuestionNavKey = keyof typeof zh
|
|
@@ -15,11 +15,22 @@
|
|
|
15
15
|
pointer-events: none;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
/*
|
|
19
|
-
|
|
18
|
+
/* Right-anchored rail: release the CSS `left: 0` so the inline `right`
|
|
19
|
+
(written by the layout-correction loop) owns the position. */
|
|
20
|
+
.railRight {
|
|
21
|
+
left: auto;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/* Vertically center the dot column when it is short; when it is tall it is
|
|
25
|
+
clamped to at most 60% of the conversation height (auto margins center the
|
|
26
|
+
band) and scrolls from the top (auto margins collapse to 0 on overflow).
|
|
27
|
+
The band itself is interactive so wheel-scrolling works over the gaps too
|
|
28
|
+
(the rail above is pointer-events:none). */
|
|
20
29
|
.list {
|
|
21
|
-
flex: 1;
|
|
30
|
+
flex: 0 1 auto;
|
|
22
31
|
min-height: 0;
|
|
32
|
+
max-height: 60%;
|
|
33
|
+
margin: auto 0;
|
|
23
34
|
width: 100%;
|
|
24
35
|
overflow-y: auto;
|
|
25
36
|
overflow-x: hidden;
|
|
@@ -29,6 +40,7 @@
|
|
|
29
40
|
gap: 6px;
|
|
30
41
|
padding: 8px 0;
|
|
31
42
|
scrollbar-width: thin;
|
|
43
|
+
pointer-events: auto;
|
|
32
44
|
}
|
|
33
45
|
.list > *:first-child {
|
|
34
46
|
margin-top: auto;
|
|
@@ -49,12 +61,15 @@
|
|
|
49
61
|
cursor: pointer;
|
|
50
62
|
transition: transform 120ms ease, background 120ms ease;
|
|
51
63
|
}
|
|
64
|
+
/* Scale is driven by the focus model through an inline transform; this only
|
|
65
|
+
colors the hovered (selected) dot instantly, before React applies it. */
|
|
52
66
|
.dot:hover {
|
|
53
|
-
|
|
67
|
+
background: var(--dsw-alias-brand-primary);
|
|
68
|
+
}
|
|
69
|
+
.dot.focused {
|
|
54
70
|
background: var(--dsw-alias-brand-primary);
|
|
55
71
|
}
|
|
56
72
|
.dot.active {
|
|
57
|
-
transform: scale(1.6);
|
|
58
73
|
background: var(--dsw-alias-brand-primary);
|
|
59
74
|
}
|
|
60
75
|
|
|
@@ -85,36 +100,152 @@
|
|
|
85
100
|
word-break: break-word;
|
|
86
101
|
}
|
|
87
102
|
|
|
88
|
-
/*
|
|
89
|
-
|
|
90
|
-
.
|
|
103
|
+
/* Transient jump-failure hint (portal-rendered near the rail top, so it never
|
|
104
|
+
pushes the centered dot band or clips in the 44px-wide rail). */
|
|
105
|
+
.hint {
|
|
106
|
+
position: fixed;
|
|
107
|
+
z-index: 30;
|
|
108
|
+
max-width: 260px;
|
|
109
|
+
background: var(--dsw-alias-bg-layer-2);
|
|
110
|
+
border: 1px solid var(--dsw-alias-border-l1);
|
|
111
|
+
border-radius: 6px;
|
|
112
|
+
padding: 6px 10px;
|
|
113
|
+
font-size: 12px;
|
|
114
|
+
line-height: 16px;
|
|
115
|
+
color: var(--dsw-alias-label-secondary);
|
|
116
|
+
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.18);
|
|
117
|
+
pointer-events: none;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/* The vertical focus cascade: one crisp question card per window dot, stacked
|
|
121
|
+
top-to-bottom without overlap, centered on the selected dot. The container
|
|
122
|
+
stays pass-through; only the cards are interactive. */
|
|
123
|
+
.cascade {
|
|
91
124
|
position: fixed;
|
|
92
125
|
z-index: 20;
|
|
93
|
-
|
|
126
|
+
display: flex;
|
|
127
|
+
flex-direction: column;
|
|
128
|
+
align-items: flex-start;
|
|
129
|
+
gap: 6px;
|
|
130
|
+
pointer-events: none;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/* One focus question card (portal-rendered in the cascade). Solid, crisp —
|
|
134
|
+
no blur — with the width set per cascade tier. Clicking jumps to the
|
|
135
|
+
question, like the dot. */
|
|
136
|
+
.card {
|
|
137
|
+
width: 380px;
|
|
94
138
|
background: var(--dsw-alias-bg-layer-1);
|
|
95
139
|
border: 1px solid var(--dsw-alias-border-l1);
|
|
96
|
-
border-radius:
|
|
97
|
-
padding: 8px 12px;
|
|
140
|
+
border-radius: 10px;
|
|
141
|
+
padding: 8px 12px 10px;
|
|
98
142
|
font-size: 13px;
|
|
99
143
|
line-height: 18px;
|
|
100
144
|
color: var(--dsw-alias-label-primary);
|
|
101
|
-
box-shadow: 0 4px
|
|
145
|
+
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.18);
|
|
102
146
|
white-space: normal;
|
|
103
147
|
word-break: break-word;
|
|
104
|
-
pointer-events:
|
|
148
|
+
pointer-events: auto;
|
|
149
|
+
cursor: pointer;
|
|
150
|
+
transition: border-color 120ms ease, box-shadow 120ms ease;
|
|
151
|
+
}
|
|
152
|
+
.card:hover {
|
|
153
|
+
border-color: var(--dsw-alias-brand-primary);
|
|
154
|
+
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.26);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/* The selected (center) card: marked brighter with a brand edge, so the
|
|
158
|
+
center card is distinct from the four unselected cascade cards. */
|
|
159
|
+
.cardSelected {
|
|
160
|
+
border-color: var(--dsw-alias-brand-primary);
|
|
105
161
|
}
|
|
106
162
|
|
|
107
|
-
/* Turn label line at the top of the
|
|
108
|
-
.
|
|
163
|
+
/* Turn label line at the top of the card ("Turn 32"). */
|
|
164
|
+
.cardTitle {
|
|
109
165
|
font-size: 11px;
|
|
110
166
|
font-weight: 600;
|
|
111
167
|
color: var(--dsw-alias-label-tertiary);
|
|
112
168
|
margin-bottom: 4px;
|
|
113
169
|
}
|
|
114
170
|
|
|
171
|
+
/* Question body; long multi-question turns scroll inside the card. */
|
|
172
|
+
.cardBody {
|
|
173
|
+
max-height: 96px;
|
|
174
|
+
overflow-y: auto;
|
|
175
|
+
scrollbar-width: thin;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/* Clamped cascade-card text (the two neighbors on each side): N lines with
|
|
179
|
+
ellipsis via -webkit-line-clamp (set inline). */
|
|
180
|
+
.cardClamp {
|
|
181
|
+
font-size: 13px;
|
|
182
|
+
line-height: 18px;
|
|
183
|
+
color: var(--dsw-alias-label-primary);
|
|
184
|
+
display: -webkit-box;
|
|
185
|
+
-webkit-box-orient: vertical;
|
|
186
|
+
overflow: hidden;
|
|
187
|
+
word-break: break-word;
|
|
188
|
+
}
|
|
189
|
+
|
|
115
190
|
/* One question text line; consecutive lines (multi-question turns) separate. */
|
|
116
|
-
.
|
|
191
|
+
.cardLine + .cardLine {
|
|
117
192
|
margin-top: 6px;
|
|
118
193
|
padding-top: 6px;
|
|
119
194
|
border-top: 1px solid var(--dsw-alias-border-l1);
|
|
120
195
|
}
|
|
196
|
+
|
|
197
|
+
/* Question sent-time line (HH:MM / MM-DD HH:MM / YYYY-MM-DD HH:MM). */
|
|
198
|
+
.cardTime {
|
|
199
|
+
margin-top: 6px;
|
|
200
|
+
font-size: 11px;
|
|
201
|
+
color: var(--dsw-alias-label-tertiary);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/* Settings page (settings.plugins.tab). */
|
|
205
|
+
.settings {
|
|
206
|
+
display: flex;
|
|
207
|
+
flex-direction: column;
|
|
208
|
+
gap: 8px;
|
|
209
|
+
padding: 4px 0;
|
|
210
|
+
}
|
|
211
|
+
.settingsTitle {
|
|
212
|
+
font-size: 13px;
|
|
213
|
+
font-weight: 600;
|
|
214
|
+
color: var(--dsw-alias-label-primary);
|
|
215
|
+
margin: 0;
|
|
216
|
+
}
|
|
217
|
+
.settingsDesc {
|
|
218
|
+
font-size: 12px;
|
|
219
|
+
line-height: 18px;
|
|
220
|
+
color: var(--dsw-alias-label-secondary);
|
|
221
|
+
margin: 0;
|
|
222
|
+
}
|
|
223
|
+
.segmented {
|
|
224
|
+
display: inline-flex;
|
|
225
|
+
border: 1px solid var(--dsw-alias-border-l1);
|
|
226
|
+
border-radius: 8px;
|
|
227
|
+
overflow: hidden;
|
|
228
|
+
align-self: flex-start;
|
|
229
|
+
}
|
|
230
|
+
.segment {
|
|
231
|
+
border: none;
|
|
232
|
+
background: transparent;
|
|
233
|
+
color: var(--dsw-alias-label-secondary);
|
|
234
|
+
font-size: 13px;
|
|
235
|
+
line-height: 1;
|
|
236
|
+
padding: 8px 16px;
|
|
237
|
+
cursor: pointer;
|
|
238
|
+
transition: background 120ms ease, color 120ms ease;
|
|
239
|
+
}
|
|
240
|
+
.segment + .segment {
|
|
241
|
+
border-left: 1px solid var(--dsw-alias-border-l1);
|
|
242
|
+
}
|
|
243
|
+
.segment:hover {
|
|
244
|
+
background: var(--dsw-alias-bg-layer-1);
|
|
245
|
+
color: var(--dsw-alias-label-primary);
|
|
246
|
+
}
|
|
247
|
+
.segmentActive,
|
|
248
|
+
.segmentActive:hover {
|
|
249
|
+
background: var(--dsw-alias-brand-primary);
|
|
250
|
+
color: var(--dsw-alias-label-inverse);
|
|
251
|
+
}
|