@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 +12 -4
- package/README.zh.md +10 -3
- package/lib/client.js +128 -57
- package/lib/client.js.map +1 -1
- package/lib/index.js +30 -5
- package/lib/types/client/QuestionNavSettingsTab.d.ts +4 -3
- package/lib/types/client/QuestionNavStrip.d.ts +7 -2
- package/lib/types/client/locales.d.ts +8 -4
- package/lib/types/client/settings.d.ts +13 -7
- package/lib/types/core/focus.d.ts +3 -16
- package/lib/types/core/page-size.d.ts +16 -0
- package/lib/types/settings.d.ts +8 -4
- package/package.json +1 -1
- package/src/client/QuestionNavSettingsTab.tsx +24 -5
- package/src/client/QuestionNavStrip.tsx +82 -47
- package/src/client/index.ts +3 -1
- package/src/client/locales.ts +8 -4
- package/src/client/question-nav.module.css +61 -17
- package/src/client/settings.ts +36 -13
- package/src/core/focus.ts +3 -34
- package/src/core/page-size.ts +20 -0
- package/src/settings.ts +12 -4
|
@@ -1,28 +1,32 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Browser mirror of the `question-nav` settings namespace: reads the rail
|
|
3
|
-
* anchor edge from the settings scope
|
|
4
|
-
* the user's
|
|
5
|
-
* registered by the host half
|
|
3
|
+
* anchor edge and the ▲/▼ page size from the settings scope
|
|
4
|
+
* (`ctx.settingsScope.bind`) and routes the user's choices back through
|
|
5
|
+
* `scope.set`. The namespace itself is registered by the host half
|
|
6
|
+
* (src/settings.ts).
|
|
6
7
|
*
|
|
7
8
|
* The settings surface is optional and may apply after this plugin, so the
|
|
8
|
-
* controller starts unbound and degrades to the
|
|
9
|
-
*
|
|
10
|
-
*
|
|
9
|
+
* controller starts unbound and degrades to the defaults until {@link attach}
|
|
10
|
+
* binds the scope (called from a fiber that injects `settingsScope`). The
|
|
11
|
+
* plugin keeps working everywhere it already did.
|
|
11
12
|
*
|
|
12
13
|
* @module dsh-question-nav/client/settings
|
|
13
14
|
*/
|
|
14
15
|
import type { SettingsScope, SettingsScopeSpec } from '@deepseek-ai/dsh-client-runtime/client';
|
|
15
16
|
import { type AlignPreference } from '../core/align.ts';
|
|
17
|
+
import { type PageSize } from '../core/page-size.ts';
|
|
16
18
|
/** The minimal face of the settings scope service this controller needs.
|
|
17
19
|
* Kept structural (bind only) so the controller stays decoupled from the
|
|
18
20
|
* full service and is unit-testable with a stub binder. */
|
|
19
21
|
export interface SettingsScopeBinderLike {
|
|
20
22
|
bind<T>(spec: SettingsScopeSpec<T>): SettingsScope<T>;
|
|
21
23
|
}
|
|
22
|
-
/** Snapshot consumed by the strip and the settings
|
|
24
|
+
/** Snapshot consumed by the strip and the settings rows. */
|
|
23
25
|
export interface QuestionNavSettingsState {
|
|
24
26
|
/** Last accepted anchor edge (default while the scope is absent/loading). */
|
|
25
27
|
align: AlignPreference;
|
|
28
|
+
/** Last accepted page size (default while the scope is absent/loading). */
|
|
29
|
+
pageSize: PageSize;
|
|
26
30
|
/** Whether the user layer overrides the composition default. */
|
|
27
31
|
overridden: boolean;
|
|
28
32
|
}
|
|
@@ -49,4 +53,6 @@ export declare class QuestionNavSettingsController {
|
|
|
49
53
|
subscribe(listener: () => void): () => void;
|
|
50
54
|
/** Route the user's anchor-edge choice to the Host document. */
|
|
51
55
|
setAlign(align: AlignPreference): void;
|
|
56
|
+
/** Route the user's page-size choice to the Host document. */
|
|
57
|
+
setPageSize(pageSize: PageSize): void;
|
|
52
58
|
}
|
|
@@ -42,24 +42,11 @@ export declare function focusCardMetrics(distance: number): FocusCardMetrics | n
|
|
|
42
42
|
* of range.
|
|
43
43
|
*/
|
|
44
44
|
export declare function magnificationWindow(total: number, selected: number, radius?: number): number[];
|
|
45
|
-
/** Clearance (px) kept around the focused dot when scrolling it into view, so
|
|
46
|
-
* its two magnified neighbors on each side stay inside the band's clear area
|
|
47
|
-
* (2 dot rows ≈ 28px + the 22px fade zone). */
|
|
48
|
-
export declare const FOCUS_NEIGHBOR_CLEARANCE = 50;
|
|
49
|
-
/**
|
|
50
|
-
* Minimal "scroll into view" for the focused dot, in the spirit of
|
|
51
|
-
* scrollIntoView({ block: 'nearest' }): returns the scrollTop that brings the
|
|
52
|
-
* dot — plus `clearance` room for its magnified neighbors — inside the
|
|
53
|
-
* visible band with the smallest possible movement, or null when the dot is
|
|
54
|
-
* already fully visible. Unlike unconditional centering this never shifts the
|
|
55
|
-
* band while the user browses dot-by-dot: only a clipped dot is scrolled.
|
|
56
|
-
*/
|
|
57
|
-
export declare function minimalScrollIntoView(scrollTop: number, clientHeight: number, scrollHeight: number, dotTop: number, dotHeight: number, clearance?: number): number | null;
|
|
58
45
|
/**
|
|
59
46
|
* Paging model for the dot band. Overflowing dots are not auto-scrolled by
|
|
60
|
-
* hovering the edges: instead the user pages them
|
|
61
|
-
* (▲ above the dot queue, ▼ below it), each click
|
|
62
|
-
* hidden dots. Pure arithmetic — no React, no DOM.
|
|
47
|
+
* hovering the edges or hovering a clipped dot: instead the user pages them
|
|
48
|
+
* with two triangle buttons (▲ above the dot queue, ▼ below it), each click
|
|
49
|
+
* revealing `DOT_PAGE_ROWS` hidden dots. Pure arithmetic — no React, no DOM.
|
|
63
50
|
*/
|
|
64
51
|
/** How many hidden dots one click of a paging triangle reveals. */
|
|
65
52
|
export declare const DOT_PAGE_ROWS = 5;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Page-size constants for the ▲/▼ paging buttons, shared by the host schema
|
|
3
|
+
* and the browser settings scope. Pure data: no DSH imports, so the client
|
|
4
|
+
* bundle may inline this module (a Host import here would leak into the
|
|
5
|
+
* browser half).
|
|
6
|
+
*
|
|
7
|
+
* @module dsh-question-nav/page-size
|
|
8
|
+
*/
|
|
9
|
+
/** Selectable page sizes: how many hidden dots one ▲/▼ click reveals. */
|
|
10
|
+
export declare const PAGE_SIZE_OPTIONS: readonly [3, 5, 8, 10];
|
|
11
|
+
/** Page-size preference (dots revealed per paging-button click). */
|
|
12
|
+
export type PageSize = typeof PAGE_SIZE_OPTIONS[number];
|
|
13
|
+
/** Default page size when the user-settings document has no override. */
|
|
14
|
+
export declare const DEFAULT_PAGE_SIZE: PageSize;
|
|
15
|
+
/** Field carrying the selected page size. */
|
|
16
|
+
export declare const PAGE_SIZE_FIELD = "pageSize";
|
package/lib/types/settings.d.ts
CHANGED
|
@@ -1,19 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Host-side durable settings for the question-nav plugin, registered into the
|
|
3
|
-
* DSH user-settings document.
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
3
|
+
* DSH user-settings document. Two fields: which edge of the conversation
|
|
4
|
+
* column the rail anchors to (`align`) and how many hidden dots one ▲/▼
|
|
5
|
+
* paging-button click reveals (`pageSize`). The browser half reads the same
|
|
6
|
+
* namespace through the settings scope (`ctx.settingsScope.bind`) and routes
|
|
7
|
+
* the user's choice back through `scope.set`.
|
|
7
8
|
*
|
|
8
9
|
* @module dsh-question-nav/settings
|
|
9
10
|
*/
|
|
10
11
|
import z from '@deepseek-ai/schemastery';
|
|
11
12
|
import type { SettingsNamespace } from '@deepseek-ai/dsh-settings';
|
|
12
13
|
import { type AlignPreference } from './core/align.ts';
|
|
14
|
+
import { type PageSize } from './core/page-size.ts';
|
|
13
15
|
/** Durable settings section shared by the Host schema and the browser scope. */
|
|
14
16
|
export interface QuestionNavSettings {
|
|
15
17
|
/** Anchor edge of the rail. */
|
|
16
18
|
align: AlignPreference;
|
|
19
|
+
/** Dots revealed per ▲/▼ paging-button click. */
|
|
20
|
+
pageSize: PageSize;
|
|
17
21
|
}
|
|
18
22
|
/** Durable settings schema; also the wire envelope the browser scope validates against. */
|
|
19
23
|
export declare const QuestionNavSettingsSchema: z<QuestionNavSettings>;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@luziyang2026/dsh-question-nav",
|
|
3
3
|
"description": "In-session question navigator for the DSH web GUI: a vertical minimap of round dots overlaid on the edge of the conversation column (left or right, configurable in settings), one dot per user question — hovering focuses a magnified window and a vertical cascade of crisp question cards showing the full text and sent time of each (any card is clickable), click jumps to that message.",
|
|
4
|
-
"version": "0.7.
|
|
4
|
+
"version": "0.7.4",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"packageManager": "pnpm@11.7.0",
|
|
7
7
|
"engines": {
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The plugin's settings page inside the shell's Plugins section
|
|
3
|
-
* (`settings.plugins.tab`):
|
|
4
|
-
* conversation column the dot rail anchors to
|
|
3
|
+
* (`settings.plugins.tab`): two segmented controls — which edge of the
|
|
4
|
+
* conversation column the dot rail anchors to, and how many hidden dots one
|
|
5
|
+
* ▲/▼ paging-button click reveals. The choices are written to the
|
|
5
6
|
* `question-nav` settings namespace (registered by the host half); the strip
|
|
6
|
-
* re-anchors live when the snapshot changes.
|
|
7
|
+
* re-anchors and re-steps live when the snapshot changes.
|
|
7
8
|
*
|
|
8
9
|
* @module dsh-question-nav/client/settings-tab
|
|
9
10
|
*/
|
|
@@ -13,6 +14,7 @@ import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots
|
|
|
13
14
|
// Type-only: pulls the settings shell's SlotMap merge ('settings.plugins.tab').
|
|
14
15
|
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
|
|
15
16
|
import { ALIGN_OPTIONS } from '../core/align.ts'
|
|
17
|
+
import { PAGE_SIZE_OPTIONS } from '../core/page-size.ts'
|
|
16
18
|
import type { QuestionNavInjected } from './QuestionNavStrip.tsx'
|
|
17
19
|
import type { QuestionNavKey } from './locales.ts'
|
|
18
20
|
import styles from './question-nav.module.css'
|
|
@@ -20,14 +22,15 @@ import styles from './question-nav.module.css'
|
|
|
20
22
|
type ComponentProps = PropsRuntime<'settings.plugins.tab'> & QuestionNavInjected & PropsLocale<'question-nav'>
|
|
21
23
|
|
|
22
24
|
/** Re-render on settings snapshot changes (the register inject face is static). */
|
|
23
|
-
function
|
|
25
|
+
function useSettingsTick(subscribe: QuestionNavInjected['subscribeSettings']): void {
|
|
24
26
|
const [, bump] = useState(0)
|
|
25
27
|
useEffect(() => subscribe(() => bump((n) => n + 1)), [subscribe])
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
export function QuestionNavSettingsTab(props: ComponentProps): React.JSX.Element | null {
|
|
29
|
-
|
|
31
|
+
useSettingsTick(props.subscribeSettings)
|
|
30
32
|
const align = props.align()
|
|
33
|
+
const pageSize = props.pageSize()
|
|
31
34
|
const t = props.t
|
|
32
35
|
|
|
33
36
|
return (
|
|
@@ -48,6 +51,22 @@ export function QuestionNavSettingsTab(props: ComponentProps): React.JSX.Element
|
|
|
48
51
|
</button>
|
|
49
52
|
))}
|
|
50
53
|
</div>
|
|
54
|
+
<p className={styles.settingsTitle}>{t('settings.pagesize.title')}</p>
|
|
55
|
+
<p className={styles.settingsDesc}>{t('settings.pagesize.desc')}</p>
|
|
56
|
+
<div className={styles.segmented} role="radiogroup" aria-label={t('settings.pagesize.title')}>
|
|
57
|
+
{PAGE_SIZE_OPTIONS.map((option) => (
|
|
58
|
+
<button
|
|
59
|
+
key={option}
|
|
60
|
+
type="button"
|
|
61
|
+
role="radio"
|
|
62
|
+
aria-checked={pageSize === option}
|
|
63
|
+
className={pageSize === option ? `${styles.segment} ${styles.segmentActive}` : styles.segment}
|
|
64
|
+
onClick={() => props.setPageSize(option)}
|
|
65
|
+
>
|
|
66
|
+
{option}
|
|
67
|
+
</button>
|
|
68
|
+
))}
|
|
69
|
+
</div>
|
|
51
70
|
</div>
|
|
52
71
|
)
|
|
53
72
|
}
|
|
@@ -12,13 +12,16 @@
|
|
|
12
12
|
* vertical cascade of question cards opens — the selected (center) card is the
|
|
13
13
|
* focus (brand accent, elevated, full question text), the four neighbors are
|
|
14
14
|
* narrower context cards clamped to fewer lines. Every card is clickable and
|
|
15
|
-
* jumps to its question, exactly like clicking the dot
|
|
16
|
-
*
|
|
15
|
+
* jumps to its question, exactly like clicking the dot. Hovering never scrolls
|
|
16
|
+
* the band — the dot column stays put while you browse.
|
|
17
17
|
*
|
|
18
18
|
* Overflow is paged, not auto-scrolled: two small triangle buttons in the dot
|
|
19
|
-
* style sit above and below the dot queue (▲ / ▼), each click revealing
|
|
20
|
-
* hidden dots
|
|
21
|
-
*
|
|
19
|
+
* style sit above and below the dot queue (▲ / ▼), each click revealing one
|
|
20
|
+
* page of hidden dots (the page size is configurable in the plugin settings,
|
|
21
|
+
* default 5) with a staggered pop-in animation as click feedback. The native
|
|
22
|
+
* scrollbar stays hidden; the triangles themselves are the overflow cue (each
|
|
23
|
+
* appears only while its direction has more to reveal) — no hover auto-scroll
|
|
24
|
+
* of any kind.
|
|
22
25
|
*
|
|
23
26
|
* Data source: the host-folded `questionIndex` session projection (whole
|
|
24
27
|
* history, persisted host-side, pushed live through session/projection
|
|
@@ -41,8 +44,9 @@ import type { QuestionNode } from '../core/nodes.ts'
|
|
|
41
44
|
import type { QuestionEntry } from '../core/question-entry.ts'
|
|
42
45
|
import { groupQuestionsByTurn, mergeLiveQuestions, type TurnDot } from '../core/turn-dots.ts'
|
|
43
46
|
import type { AlignPreference } from '../core/align.ts'
|
|
47
|
+
import type { PageSize } from '../core/page-size.ts'
|
|
44
48
|
import type { JumpFailureCode } from '../core/jump.ts'
|
|
45
|
-
import { FOCUS_RADIUS, clampScrollTop, focusCardMetrics, focusScale, focusTier,
|
|
49
|
+
import { DOT_GAP, DOT_SIZE, FOCUS_RADIUS, clampScrollTop, focusCardMetrics, focusScale, focusTier, pageStep } from '../core/focus.ts'
|
|
46
50
|
import { formatQuestionTime } from '../core/time.ts'
|
|
47
51
|
import type { QuestionNavKey } from './locales.ts'
|
|
48
52
|
import styles from './question-nav.module.css'
|
|
@@ -69,10 +73,14 @@ export interface QuestionNavInjected {
|
|
|
69
73
|
jump: (sessionId: SessionId, key: string) => void
|
|
70
74
|
/** Current rail anchor edge (defaults to 'left' before the settings section is ready). */
|
|
71
75
|
align: () => AlignPreference
|
|
72
|
-
/** Observe
|
|
73
|
-
|
|
76
|
+
/** Observe plugin-settings changes (align, page size); returns an unsubscribe. */
|
|
77
|
+
subscribeSettings: (cb: () => void) => () => void
|
|
74
78
|
/** Persist a new anchor edge. */
|
|
75
79
|
setAlign: (align: AlignPreference) => void
|
|
80
|
+
/** Current ▲/▼ page size (defaults to DEFAULT_PAGE_SIZE before the settings section is ready). */
|
|
81
|
+
pageSize: () => PageSize
|
|
82
|
+
/** Persist a new page size. */
|
|
83
|
+
setPageSize: (pageSize: PageSize) => void
|
|
76
84
|
}
|
|
77
85
|
|
|
78
86
|
type ComponentProps = PropsRuntime<'shell.overlay'> & QuestionNavInjected & PropsLocale<'question-nav'>
|
|
@@ -146,6 +154,8 @@ export function QuestionNavStrip(props: ComponentProps): React.JSX.Element | nul
|
|
|
146
154
|
const [hint, setHint] = useState<string | null>(null)
|
|
147
155
|
const [focus, setFocus] = useState<FocusState | null>(null)
|
|
148
156
|
const [align, setAlign] = useState<AlignPreference>(() => props.align())
|
|
157
|
+
// Dots revealed per ▲/▼ click (settings scope; drives pageBy's step).
|
|
158
|
+
const [pageSize, setPageSize] = useState<PageSize>(() => props.pageSize())
|
|
149
159
|
const panelRef = useRef<HTMLDivElement | null>(null)
|
|
150
160
|
const listRef = useRef<HTMLDivElement | null>(null)
|
|
151
161
|
const hintTimerRef = useRef<number | null>(null)
|
|
@@ -154,14 +164,17 @@ export function QuestionNavStrip(props: ComponentProps): React.JSX.Element | nul
|
|
|
154
164
|
const clearFocusTimerRef = useRef<number | null>(null)
|
|
155
165
|
// Last rendered dot list, for the change-detection bail-out below.
|
|
156
166
|
const lastDotsRef = useRef<TurnDot[]>([])
|
|
157
|
-
//
|
|
158
|
-
|
|
159
|
-
// Whether the dot band overflows its 60% clamp (drives the edge-fade mask
|
|
160
|
-
// and the ▲/▼ paging buttons).
|
|
167
|
+
// Whether the dot band overflows its 60% clamp (drives the ▲/▼ paging
|
|
168
|
+
// buttons).
|
|
161
169
|
const [scrollable, setScrollable] = useState(false)
|
|
162
170
|
// Scroll offset of the band + its max offset: drives the ▲/▼ visibility
|
|
163
171
|
// (each direction hides once there is nothing more to reveal).
|
|
164
172
|
const [scrollPos, setScrollPos] = useState<{ top: number; max: number }>({ top: 0, max: 0 })
|
|
173
|
+
// Dots revealed by the latest ▲/▼ click: key → stagger order + travel
|
|
174
|
+
// direction. Drives a short staggered pop-in animation so the click has a
|
|
175
|
+
// visible effect; cleared by a timer once the animation has played out.
|
|
176
|
+
const [revealed, setRevealed] = useState<ReadonlyMap<string, { dir: 1 | -1; order: number }> | null>(null)
|
|
177
|
+
const revealTimerRef = useRef<number | null>(null)
|
|
165
178
|
|
|
166
179
|
const syncScroll = (): void => {
|
|
167
180
|
const list = listRef.current
|
|
@@ -170,13 +183,45 @@ export function QuestionNavStrip(props: ComponentProps): React.JSX.Element | nul
|
|
|
170
183
|
setScrollPos({ top: list.scrollTop, max })
|
|
171
184
|
}
|
|
172
185
|
|
|
173
|
-
// Page the band by one DOT_PAGE_ROWS click in the given direction
|
|
186
|
+
// Page the band by one DOT_PAGE_ROWS click in the given direction, then mark
|
|
187
|
+
// the dots the page just brought into view so they play a staggered pop-in
|
|
188
|
+
// animation in the direction of travel (▼ → rise from below, ▲ → drop from
|
|
189
|
+
// above) — the visual confirmation that the click revealed new dots.
|
|
174
190
|
const pageBy = (dir: 1 | -1): void => {
|
|
175
191
|
const list = listRef.current
|
|
176
192
|
if (list === null) return
|
|
177
193
|
const max = Math.max(0, list.scrollHeight - list.clientHeight)
|
|
178
|
-
|
|
194
|
+
const target = clampScrollTop(list.scrollTop + dir * pageStep(DOT_SIZE, DOT_GAP, pageSize), max)
|
|
195
|
+
if (target === list.scrollTop) return
|
|
196
|
+
// Snapshot which dots are geometrically inside the band before the jump
|
|
197
|
+
// (the container rect does not move when its content scrolls).
|
|
198
|
+
const band = list.getBoundingClientRect()
|
|
199
|
+
const els = Array.from(list.querySelectorAll<HTMLElement>('[data-question-nav-key]'))
|
|
200
|
+
const wasVisible = els.map((el) => {
|
|
201
|
+
const r = el.getBoundingClientRect()
|
|
202
|
+
return r.bottom > band.top && r.top < band.bottom
|
|
203
|
+
})
|
|
204
|
+
list.scrollTop = target
|
|
179
205
|
syncScroll()
|
|
206
|
+
const next = new Map<string, { dir: 1 | -1; order: number }>()
|
|
207
|
+
const fresh: string[] = []
|
|
208
|
+
els.forEach((el, i) => {
|
|
209
|
+
if (wasVisible[i]) return
|
|
210
|
+
const r = el.getBoundingClientRect()
|
|
211
|
+
const key = el.dataset.questionNavKey
|
|
212
|
+
if (key !== undefined && r.bottom > band.top && r.top < band.bottom) {
|
|
213
|
+
fresh.push(key)
|
|
214
|
+
}
|
|
215
|
+
})
|
|
216
|
+
// Stagger radiates from the clicked triangle: paging down (▼) starts at
|
|
217
|
+
// the bottom of the fresh batch, paging up (▲) at the top.
|
|
218
|
+
if (dir === 1) fresh.reverse()
|
|
219
|
+
fresh.forEach((key, order) => next.set(key, { dir, order }))
|
|
220
|
+
if (next.size === 0) return
|
|
221
|
+
setRevealed(next)
|
|
222
|
+
if (revealTimerRef.current !== null) window.clearTimeout(revealTimerRef.current)
|
|
223
|
+
// Animation (320ms) + per-dot stagger (35ms each) + slack.
|
|
224
|
+
revealTimerRef.current = window.setTimeout(() => setRevealed(null), 320 + next.size * 35 + 120)
|
|
180
225
|
}
|
|
181
226
|
|
|
182
227
|
const canPageUp = scrollable && scrollPos.top > 0
|
|
@@ -196,9 +241,13 @@ export function QuestionNavStrip(props: ComponentProps): React.JSX.Element | nul
|
|
|
196
241
|
clearFocusTimerRef.current = window.setTimeout(() => setFocus(null), 240)
|
|
197
242
|
}
|
|
198
243
|
|
|
199
|
-
// Follow the
|
|
200
|
-
// the rail through the layout effect below
|
|
201
|
-
|
|
244
|
+
// Follow the plugin settings from the settings scope (an align change
|
|
245
|
+
// re-anchors the rail through the layout effect below; a page-size change
|
|
246
|
+
// steps the ▲/▼ paging).
|
|
247
|
+
useEffect(() => props.subscribeSettings(() => {
|
|
248
|
+
setAlign(props.align())
|
|
249
|
+
setPageSize(props.pageSize())
|
|
250
|
+
}), [props])
|
|
202
251
|
|
|
203
252
|
const showHint = (message: string): void => {
|
|
204
253
|
setHint(message)
|
|
@@ -357,34 +406,9 @@ export function QuestionNavStrip(props: ComponentProps): React.JSX.Element | nul
|
|
|
357
406
|
}
|
|
358
407
|
}, [visible, align])
|
|
359
408
|
|
|
360
|
-
//
|
|
361
|
-
//
|
|
362
|
-
//
|
|
363
|
-
// re-center an already-visible dot, so browsing dot-by-dot doesn't shift
|
|
364
|
-
// the band under the pointer.
|
|
365
|
-
useLayoutEffect(() => {
|
|
366
|
-
const key = focus?.key ?? null
|
|
367
|
-
if (lastFocusedKeyRef.current === key) return
|
|
368
|
-
lastFocusedKeyRef.current = key
|
|
369
|
-
if (key === null) return
|
|
370
|
-
const list = listRef.current
|
|
371
|
-
if (list === null) return
|
|
372
|
-
const target = list.querySelector<HTMLElement>('[data-question-nav-focused="true"]')
|
|
373
|
-
if (target === null) return
|
|
374
|
-
const next = minimalScrollIntoView(
|
|
375
|
-
list.scrollTop,
|
|
376
|
-
list.clientHeight,
|
|
377
|
-
list.scrollHeight,
|
|
378
|
-
target.offsetTop - list.offsetTop,
|
|
379
|
-
target.offsetHeight,
|
|
380
|
-
)
|
|
381
|
-
if (next !== null) list.scrollTop = next
|
|
382
|
-
syncScroll()
|
|
383
|
-
}, [focus])
|
|
384
|
-
|
|
385
|
-
// Detect band overflow: drives the edge-fade mask + the ▲/▼ paging buttons.
|
|
386
|
-
// Re-checked when the dots change and whenever the band itself resizes
|
|
387
|
-
// (the layout loop above clamps it to the conversation height).
|
|
409
|
+
// Detect band overflow: drives the ▲/▼ paging buttons. Re-checked when the
|
|
410
|
+
// dots change and whenever the band itself resizes (the layout loop above
|
|
411
|
+
// clamps it to the conversation height).
|
|
388
412
|
useLayoutEffect(() => {
|
|
389
413
|
const list = listRef.current
|
|
390
414
|
if (list === null) return
|
|
@@ -403,6 +427,7 @@ export function QuestionNavStrip(props: ComponentProps): React.JSX.Element | nul
|
|
|
403
427
|
useEffect(() => () => {
|
|
404
428
|
if (hintTimerRef.current !== null) window.clearTimeout(hintTimerRef.current)
|
|
405
429
|
if (clearFocusTimerRef.current !== null) window.clearTimeout(clearFocusTimerRef.current)
|
|
430
|
+
if (revealTimerRef.current !== null) window.clearTimeout(revealTimerRef.current)
|
|
406
431
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
407
432
|
}, [])
|
|
408
433
|
|
|
@@ -463,7 +488,7 @@ export function QuestionNavStrip(props: ComponentProps): React.JSX.Element | nul
|
|
|
463
488
|
) : null}
|
|
464
489
|
<div
|
|
465
490
|
ref={listRef}
|
|
466
|
-
className={
|
|
491
|
+
className={styles.list}
|
|
467
492
|
onScroll={syncScroll}
|
|
468
493
|
onMouseLeave={scheduleClearFocus}
|
|
469
494
|
>
|
|
@@ -474,16 +499,26 @@ export function QuestionNavStrip(props: ComponentProps): React.JSX.Element | nul
|
|
|
474
499
|
// smaller still, the rest base scale.
|
|
475
500
|
const tier = selectedIndex < 0 ? null : focusTier(index - selectedIndex)
|
|
476
501
|
const scale = jumpingKey === dot.key ? 1.6 : tier !== null ? focusScale(tier) : 1
|
|
502
|
+
// Click-paging feedback: dots just paged into view play a
|
|
503
|
+
// staggered pop-in, traveling in the paging direction.
|
|
504
|
+
const reveal = revealed?.get(dot.key)
|
|
477
505
|
const cls = [styles.dot]
|
|
478
506
|
if (isFocused) cls.push(styles.focused)
|
|
479
507
|
if (jumpingKey === dot.key) cls.push(styles.active)
|
|
508
|
+
if (reveal !== undefined) {
|
|
509
|
+
cls.push(reveal.dir === 1 ? styles.dotEnterFromBottom : styles.dotEnterFromTop)
|
|
510
|
+
}
|
|
480
511
|
return (
|
|
481
512
|
<button
|
|
482
513
|
key={dot.key}
|
|
483
514
|
className={cls.join(' ')}
|
|
484
|
-
style={{
|
|
515
|
+
style={{
|
|
516
|
+
transform: `scale(${scale})`,
|
|
517
|
+
...(reveal !== undefined ? { animationDelay: `${reveal.order * 35}ms` } : {}),
|
|
518
|
+
}}
|
|
485
519
|
data-question-nav-focused={isFocused ? 'true' : undefined}
|
|
486
520
|
data-question-nav-index={index}
|
|
521
|
+
data-question-nav-key={dot.key}
|
|
487
522
|
aria-label={dot.texts[0] ?? ''}
|
|
488
523
|
onMouseEnter={(e) => openFocus(dot, e.currentTarget)}
|
|
489
524
|
onClick={() => onJump(dot)}
|
package/src/client/index.ts
CHANGED
|
@@ -133,8 +133,10 @@ function createInject(ctx: ClientContext, settings: QuestionNavSettingsControlle
|
|
|
133
133
|
void jumpToQuestion(ports, key)
|
|
134
134
|
},
|
|
135
135
|
align: () => settings.getSnapshot().align,
|
|
136
|
-
|
|
136
|
+
subscribeSettings: (cb) => settings.subscribe(cb),
|
|
137
137
|
setAlign: (align) => settings.setAlign(align),
|
|
138
|
+
pageSize: () => settings.getSnapshot().pageSize,
|
|
139
|
+
setPageSize: (pageSize) => settings.setPageSize(pageSize),
|
|
138
140
|
}
|
|
139
141
|
}
|
|
140
142
|
|
package/src/client/locales.ts
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
*/
|
|
5
5
|
export const zh = {
|
|
6
6
|
'strip.empty': '本会话还没有提问',
|
|
7
|
-
'strip.up': '
|
|
8
|
-
'strip.down': '
|
|
7
|
+
'strip.up': '向上翻出更多提问圆点',
|
|
8
|
+
'strip.down': '向下翻出更多提问圆点',
|
|
9
9
|
'jump.inactive': '聊天视图未激活',
|
|
10
10
|
'jump.hidden': '目标无独立气泡,已定位到邻近内容',
|
|
11
11
|
'jump.notfound': '目标未加载或不存在(可能已压缩)',
|
|
@@ -15,12 +15,14 @@ export const zh = {
|
|
|
15
15
|
'settings.align.desc': '选择圆点导航条锚定在对话栏的哪一侧。',
|
|
16
16
|
'settings.align.left': '左侧',
|
|
17
17
|
'settings.align.right': '右侧',
|
|
18
|
+
'settings.pagesize.title': '每次翻出数量',
|
|
19
|
+
'settings.pagesize.desc': '点击 ▲/▼ 三角按钮时,每次翻出的隐藏圆点数量。',
|
|
18
20
|
} as const
|
|
19
21
|
|
|
20
22
|
export const en = {
|
|
21
23
|
'strip.empty': 'No questions in this session yet',
|
|
22
|
-
'strip.up': 'Reveal
|
|
23
|
-
'strip.down': 'Reveal
|
|
24
|
+
'strip.up': 'Reveal more question dots above',
|
|
25
|
+
'strip.down': 'Reveal more question dots below',
|
|
24
26
|
'jump.inactive': 'Chat view is not active',
|
|
25
27
|
'jump.hidden': 'No dedicated bubble; landed on nearby content',
|
|
26
28
|
'jump.notfound': 'Target not loaded or missing (maybe compacted)',
|
|
@@ -30,6 +32,8 @@ export const en = {
|
|
|
30
32
|
'settings.align.desc': 'Choose which edge of the conversation column the dot rail anchors to.',
|
|
31
33
|
'settings.align.left': 'Left',
|
|
32
34
|
'settings.align.right': 'Right',
|
|
35
|
+
'settings.pagesize.title': 'Dots per page',
|
|
36
|
+
'settings.pagesize.desc': 'How many hidden dots one click of the ▲/▼ paging buttons reveals.',
|
|
33
37
|
} as const
|
|
34
38
|
|
|
35
39
|
export type QuestionNavKey = keyof typeof zh
|
|
@@ -22,11 +22,10 @@
|
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
/* The paging queue: the fixed column (count + ▲, then the scrollable dot band,
|
|
25
|
-
then ▼)
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
pointer events. */
|
|
25
|
+
then ▼). Clamped to 60% of the conversation height and vertically centered
|
|
26
|
+
(auto margins); when the queue overflows, only the middle dot band scrolls
|
|
27
|
+
— the count and triangles stay pinned. The wrapper stays pass-through; the
|
|
28
|
+
band and triangles re-enable pointer events. */
|
|
30
29
|
.queue {
|
|
31
30
|
flex: 0 1 auto;
|
|
32
31
|
min-height: 0;
|
|
@@ -44,7 +43,8 @@
|
|
|
44
43
|
/* The middle dot band: the only scrollable part of the queue. Scrolls from the
|
|
45
44
|
top (overflow collapses the auto margins to 0). The native scrollbar is
|
|
46
45
|
always hidden: this is a minimap paged by the ▲/▼ buttons and by
|
|
47
|
-
wheel/trackpad, and overflow is signalled by the
|
|
46
|
+
wheel/trackpad, and overflow is signalled by the ▲/▼ triangles themselves
|
|
47
|
+
(each appears only while its direction has more dots to reveal). */
|
|
48
48
|
.list {
|
|
49
49
|
flex: 1 1 auto;
|
|
50
50
|
min-height: 0;
|
|
@@ -64,13 +64,6 @@
|
|
|
64
64
|
height: 0;
|
|
65
65
|
display: none;
|
|
66
66
|
}
|
|
67
|
-
/* Overflowing band (applied from JS when scrollHeight > clientHeight): dots
|
|
68
|
-
fade out towards both edges — a pure visual cue for "more beyond" (the
|
|
69
|
-
paging triangles do the actual scrolling). */
|
|
70
|
-
.listScrollable {
|
|
71
|
-
-webkit-mask-image: linear-gradient(to bottom, transparent, black 22px, black calc(100% - 22px), transparent);
|
|
72
|
-
mask-image: linear-gradient(to bottom, transparent, black 22px, black calc(100% - 22px), transparent);
|
|
73
|
-
}
|
|
74
67
|
|
|
75
68
|
.dot {
|
|
76
69
|
flex: none;
|
|
@@ -96,6 +89,50 @@
|
|
|
96
89
|
background: var(--dsw-alias-brand-primary);
|
|
97
90
|
}
|
|
98
91
|
|
|
92
|
+
/* Click-paging feedback: dots revealed by a ▲/▼ click pop in with a staggered
|
|
93
|
+
fade + rise/drop (per-dot animation-delay is set inline, stagger order *
|
|
94
|
+
35ms), traveling in the paging direction so the newly revealed batch reads
|
|
95
|
+
as "the click brought these in". `both` fill holds the hidden `from` state
|
|
96
|
+
during each dot's delay. */
|
|
97
|
+
@keyframes dotEnterFromBottom {
|
|
98
|
+
from {
|
|
99
|
+
opacity: 0;
|
|
100
|
+
transform: translateY(7px) scale(0.4);
|
|
101
|
+
}
|
|
102
|
+
60% {
|
|
103
|
+
opacity: 1;
|
|
104
|
+
}
|
|
105
|
+
to {
|
|
106
|
+
opacity: 1;
|
|
107
|
+
transform: translateY(0) scale(1);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
@keyframes dotEnterFromTop {
|
|
111
|
+
from {
|
|
112
|
+
opacity: 0;
|
|
113
|
+
transform: translateY(-7px) scale(0.4);
|
|
114
|
+
}
|
|
115
|
+
60% {
|
|
116
|
+
opacity: 1;
|
|
117
|
+
}
|
|
118
|
+
to {
|
|
119
|
+
opacity: 1;
|
|
120
|
+
transform: translateY(0) scale(1);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
.dotEnterFromBottom {
|
|
124
|
+
animation: dotEnterFromBottom 320ms cubic-bezier(0.2, 0.9, 0.3, 1.2) both;
|
|
125
|
+
}
|
|
126
|
+
.dotEnterFromTop {
|
|
127
|
+
animation: dotEnterFromTop 320ms cubic-bezier(0.2, 0.9, 0.3, 1.2) both;
|
|
128
|
+
}
|
|
129
|
+
@media (prefers-reduced-motion: reduce) {
|
|
130
|
+
.dotEnterFromBottom,
|
|
131
|
+
.dotEnterFromTop {
|
|
132
|
+
animation: none;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
99
136
|
/* Question count, rendered just above the first dot (sits in the list gap). */
|
|
100
137
|
.count {
|
|
101
138
|
flex: none;
|
|
@@ -169,7 +206,7 @@
|
|
|
169
206
|
position: fixed;
|
|
170
207
|
z-index: 30;
|
|
171
208
|
max-width: 260px;
|
|
172
|
-
background: var(--dsw-alias-bg-layer-
|
|
209
|
+
background: var(--dsw-alias-bg-layer-3);
|
|
173
210
|
border: 1px solid var(--dsw-alias-border-l1);
|
|
174
211
|
border-radius: 6px;
|
|
175
212
|
padding: 6px 10px;
|
|
@@ -196,11 +233,14 @@
|
|
|
196
233
|
/* One focus question card (portal-rendered in the cascade). The four context
|
|
197
234
|
cards use the base style: a clean solid surface, a quiet neutral border and
|
|
198
235
|
a low shadow — crisp and readable, one visual step behind the focus card.
|
|
199
|
-
|
|
200
|
-
|
|
236
|
+
The surface is the theme's TOP elevation layer (bg-layer-3, the same token
|
|
237
|
+
DSH menus and popups use): these cards float above the conversation, and in
|
|
238
|
+
dark themes the layer ramp gives them the raised-surface tint. Clicking
|
|
239
|
+
jumps to the question, like the dot; hover raises the border to signal the
|
|
240
|
+
card is interactive. */
|
|
201
241
|
.card {
|
|
202
242
|
width: 380px;
|
|
203
|
-
background: var(--dsw-alias-bg-layer-
|
|
243
|
+
background: var(--dsw-alias-bg-layer-3);
|
|
204
244
|
border: 1px solid var(--dsw-alias-border-l2);
|
|
205
245
|
border-radius: 10px;
|
|
206
246
|
padding: 8px 12px 10px;
|
|
@@ -291,6 +331,10 @@
|
|
|
291
331
|
color: var(--dsw-alias-label-primary);
|
|
292
332
|
margin: 0;
|
|
293
333
|
}
|
|
334
|
+
/* Separate consecutive setting groups (align, then page size). */
|
|
335
|
+
.segmented + .settingsTitle {
|
|
336
|
+
margin-top: 12px;
|
|
337
|
+
}
|
|
294
338
|
.settingsDesc {
|
|
295
339
|
font-size: 12px;
|
|
296
340
|
line-height: 18px;
|