@luziyang2026/dsh-question-nav 0.7.1 → 0.7.3
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 +6 -4
- package/README.zh.md +4 -3
- package/lib/client.js +91 -127
- package/lib/client.js.map +1 -1
- package/lib/types/client/locales.d.ts +4 -0
- package/lib/types/core/focus.d.ts +18 -29
- package/package.json +1 -1
- package/src/client/QuestionNavStrip.tsx +86 -145
- package/src/client/locales.ts +4 -0
- package/src/client/question-nav.module.css +66 -17
- package/src/core/focus.ts +27 -48
|
@@ -12,8 +12,13 @@
|
|
|
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
|
+
*
|
|
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 five
|
|
20
|
+
* hidden dots. The native scrollbar stays hidden and the column fades at its
|
|
21
|
+
* edges as a pure visual cue — no hover auto-scroll of any kind.
|
|
17
22
|
*
|
|
18
23
|
* Data source: the host-folded `questionIndex` session projection (whole
|
|
19
24
|
* history, persisted host-side, pushed live through session/projection
|
|
@@ -37,7 +42,7 @@ import type { QuestionEntry } from '../core/question-entry.ts'
|
|
|
37
42
|
import { groupQuestionsByTurn, mergeLiveQuestions, type TurnDot } from '../core/turn-dots.ts'
|
|
38
43
|
import type { AlignPreference } from '../core/align.ts'
|
|
39
44
|
import type { JumpFailureCode } from '../core/jump.ts'
|
|
40
|
-
import {
|
|
45
|
+
import { FOCUS_RADIUS, clampScrollTop, focusCardMetrics, focusScale, focusTier, pageStep } from '../core/focus.ts'
|
|
41
46
|
import { formatQuestionTime } from '../core/time.ts'
|
|
42
47
|
import type { QuestionNavKey } from './locales.ts'
|
|
43
48
|
import styles from './question-nav.module.css'
|
|
@@ -149,94 +154,32 @@ export function QuestionNavStrip(props: ComponentProps): React.JSX.Element | nul
|
|
|
149
154
|
const clearFocusTimerRef = useRef<number | null>(null)
|
|
150
155
|
// Last rendered dot list, for the change-detection bail-out below.
|
|
151
156
|
const lastDotsRef = useRef<TurnDot[]>([])
|
|
152
|
-
// Last focused dot key, so re-hovering the same dot after a gap re-centers it.
|
|
153
|
-
const lastFocusedKeyRef = useRef<string | null>(null)
|
|
154
157
|
// Whether the dot band overflows its 60% clamp (drives the edge-fade mask
|
|
155
|
-
// and the
|
|
158
|
+
// and the ▲/▼ paging buttons).
|
|
156
159
|
const [scrollable, setScrollable] = useState(false)
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
|
|
160
|
-
// the zone; a rAF loop applies it smoothly while it is non-zero.
|
|
161
|
-
const edgeScrollRef = useRef<{ speed: number; raf: number; last: number }>({ speed: 0, raf: 0, last: 0 })
|
|
162
|
-
// Hover-intent dwell timer: auto-scroll only starts after the pointer rests
|
|
163
|
-
// in a fade zone for EDGE_SCROLL_DWELL_MS, so browsing dot-by-dot (or just
|
|
164
|
-
// passing through the zone) never triggers an unwanted scroll.
|
|
165
|
-
const edgeDwellRef = useRef<number | null>(null)
|
|
166
|
-
|
|
167
|
-
const cancelEdgeDwell = (): void => {
|
|
168
|
-
if (edgeDwellRef.current !== null) {
|
|
169
|
-
window.clearTimeout(edgeDwellRef.current)
|
|
170
|
-
edgeDwellRef.current = null
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
const stopEdgeScroll = (): void => {
|
|
175
|
-
cancelEdgeDwell()
|
|
176
|
-
edgeScrollRef.current.speed = 0
|
|
177
|
-
if (edgeScrollRef.current.raf !== 0) {
|
|
178
|
-
cancelAnimationFrame(edgeScrollRef.current.raf)
|
|
179
|
-
edgeScrollRef.current.raf = 0
|
|
180
|
-
}
|
|
181
|
-
}
|
|
160
|
+
// Scroll offset of the band + its max offset: drives the ▲/▼ visibility
|
|
161
|
+
// (each direction hides once there is nothing more to reveal).
|
|
162
|
+
const [scrollPos, setScrollPos] = useState<{ top: number; max: number }>({ top: 0, max: 0 })
|
|
182
163
|
|
|
183
|
-
const
|
|
164
|
+
const syncScroll = (): void => {
|
|
184
165
|
const list = listRef.current
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
const dt = Math.min(64, now - state.last) / 1000
|
|
189
|
-
state.last = now
|
|
190
|
-
const before = list.scrollTop
|
|
191
|
-
list.scrollTop = before + state.speed * dt
|
|
192
|
-
// Stop at the scroll bounds — there is nothing more to reveal.
|
|
193
|
-
const atTop = list.scrollTop <= 0 && state.speed < 0
|
|
194
|
-
const atBottom = list.scrollTop >= list.scrollHeight - list.clientHeight - 1 && state.speed > 0
|
|
195
|
-
if (atTop || atBottom) {
|
|
196
|
-
state.speed = 0
|
|
197
|
-
return
|
|
198
|
-
}
|
|
199
|
-
state.raf = requestAnimationFrame(edgeTick)
|
|
166
|
+
if (list === null) return
|
|
167
|
+
const max = Math.max(0, list.scrollHeight - list.clientHeight)
|
|
168
|
+
setScrollPos({ top: list.scrollTop, max })
|
|
200
169
|
}
|
|
201
170
|
|
|
202
|
-
|
|
171
|
+
// Page the band by one DOT_PAGE_ROWS click in the given direction.
|
|
172
|
+
const pageBy = (dir: 1 | -1): void => {
|
|
203
173
|
const list = listRef.current
|
|
204
174
|
if (list === null) return
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
const rect = list.getBoundingClientRect()
|
|
209
|
-
const y = e.clientY - rect.top
|
|
210
|
-
const depthTop = EDGE_SCROLL_ZONE - y
|
|
211
|
-
const depthBottom = y - (rect.height - EDGE_SCROLL_ZONE)
|
|
212
|
-
let speed = 0
|
|
213
|
-
if (!overDot) {
|
|
214
|
-
if (depthTop > 0 && list.scrollTop > 0) {
|
|
215
|
-
speed = -edgeScrollSpeed(depthTop / EDGE_SCROLL_ZONE)
|
|
216
|
-
} else if (depthBottom > 0 && list.scrollTop < list.scrollHeight - list.clientHeight - 1) {
|
|
217
|
-
speed = edgeScrollSpeed(depthBottom / EDGE_SCROLL_ZONE)
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
const state = edgeScrollRef.current
|
|
221
|
-
state.speed = speed
|
|
222
|
-
if (speed === 0) {
|
|
223
|
-
// Left the zone (or moved onto a dot): cancel any pending dwell and stop.
|
|
224
|
-
stopEdgeScroll()
|
|
225
|
-
return
|
|
226
|
-
}
|
|
227
|
-
if (state.raf === 0 && edgeDwellRef.current === null) {
|
|
228
|
-
// Start only after the pointer has rested in the zone for the dwell
|
|
229
|
-
// time; re-check at fire time because the pointer may have left.
|
|
230
|
-
edgeDwellRef.current = window.setTimeout(() => {
|
|
231
|
-
edgeDwellRef.current = null
|
|
232
|
-
if (edgeScrollRef.current.speed !== 0 && edgeScrollRef.current.raf === 0) {
|
|
233
|
-
edgeScrollRef.current.last = performance.now()
|
|
234
|
-
edgeScrollRef.current.raf = requestAnimationFrame(edgeTick)
|
|
235
|
-
}
|
|
236
|
-
}, EDGE_SCROLL_DWELL_MS)
|
|
237
|
-
}
|
|
175
|
+
const max = Math.max(0, list.scrollHeight - list.clientHeight)
|
|
176
|
+
list.scrollTop = clampScrollTop(list.scrollTop + dir * pageStep(), max)
|
|
177
|
+
syncScroll()
|
|
238
178
|
}
|
|
239
179
|
|
|
180
|
+
const canPageUp = scrollable && scrollPos.top > 0
|
|
181
|
+
const canPageDown = scrollable && scrollPos.top < scrollPos.max
|
|
182
|
+
|
|
240
183
|
// Focus persists briefly after leaving the rail, so the mouse can reach the
|
|
241
184
|
// clickable cascade cards; entering a card cancels the clear, leaving
|
|
242
185
|
// everything re-arms it.
|
|
@@ -412,40 +355,16 @@ export function QuestionNavStrip(props: ComponentProps): React.JSX.Element | nul
|
|
|
412
355
|
}
|
|
413
356
|
}, [visible, align])
|
|
414
357
|
|
|
415
|
-
//
|
|
416
|
-
// scroll only when the dot (plus room for its two magnified neighbors) is
|
|
417
|
-
// clipped by the band edges, and then only by the minimal amount — never
|
|
418
|
-
// re-center an already-visible dot, so browsing dot-by-dot doesn't shift
|
|
419
|
-
// the band under the pointer. Suppressed while the edge auto-scroll is
|
|
420
|
-
// driving: hovering dots that flow under a parked pointer would otherwise
|
|
421
|
-
// fight the scroll.
|
|
422
|
-
useLayoutEffect(() => {
|
|
423
|
-
const key = focus?.key ?? null
|
|
424
|
-
if (lastFocusedKeyRef.current === key) return
|
|
425
|
-
lastFocusedKeyRef.current = key
|
|
426
|
-
if (key === null) return
|
|
427
|
-
if (edgeScrollRef.current.raf !== 0) return
|
|
428
|
-
const list = listRef.current
|
|
429
|
-
if (list === null) return
|
|
430
|
-
const target = list.querySelector<HTMLElement>('[data-question-nav-focused="true"]')
|
|
431
|
-
if (target === null) return
|
|
432
|
-
const next = minimalScrollIntoView(
|
|
433
|
-
list.scrollTop,
|
|
434
|
-
list.clientHeight,
|
|
435
|
-
list.scrollHeight,
|
|
436
|
-
target.offsetTop - list.offsetTop,
|
|
437
|
-
target.offsetHeight,
|
|
438
|
-
)
|
|
439
|
-
if (next !== null) list.scrollTop = next
|
|
440
|
-
}, [focus])
|
|
441
|
-
|
|
442
|
-
// Detect band overflow: drives the edge-fade mask + hover auto-scroll.
|
|
358
|
+
// Detect band overflow: drives the edge-fade mask + the ▲/▼ paging buttons.
|
|
443
359
|
// Re-checked when the dots change and whenever the band itself resizes
|
|
444
360
|
// (the layout loop above clamps it to the conversation height).
|
|
445
361
|
useLayoutEffect(() => {
|
|
446
362
|
const list = listRef.current
|
|
447
363
|
if (list === null) return
|
|
448
|
-
const check = (): void =>
|
|
364
|
+
const check = (): void => {
|
|
365
|
+
setScrollable(list.scrollHeight > list.clientHeight + 1)
|
|
366
|
+
syncScroll()
|
|
367
|
+
}
|
|
449
368
|
check()
|
|
450
369
|
if (typeof ResizeObserver === 'undefined') return
|
|
451
370
|
const observer = new ResizeObserver(check)
|
|
@@ -453,11 +372,10 @@ export function QuestionNavStrip(props: ComponentProps): React.JSX.Element | nul
|
|
|
453
372
|
return () => observer.disconnect()
|
|
454
373
|
}, [dots, align])
|
|
455
374
|
|
|
456
|
-
// Clear any pending timers
|
|
375
|
+
// Clear any pending timers on unmount.
|
|
457
376
|
useEffect(() => () => {
|
|
458
377
|
if (hintTimerRef.current !== null) window.clearTimeout(hintTimerRef.current)
|
|
459
378
|
if (clearFocusTimerRef.current !== null) window.clearTimeout(clearFocusTimerRef.current)
|
|
460
|
-
stopEdgeScroll()
|
|
461
379
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
462
380
|
}, [])
|
|
463
381
|
|
|
@@ -499,43 +417,66 @@ export function QuestionNavStrip(props: ComponentProps): React.JSX.Element | nul
|
|
|
499
417
|
className={align === 'right' ? `${styles.rail} ${styles.railRight}` : styles.rail}
|
|
500
418
|
data-question-nav="rail"
|
|
501
419
|
>
|
|
502
|
-
<div
|
|
503
|
-
ref={listRef}
|
|
504
|
-
className={scrollable ? `${styles.list} ${styles.listScrollable}` : styles.list}
|
|
505
|
-
onMouseMove={scrollable ? onListMouseMove : undefined}
|
|
506
|
-
onMouseLeave={() => {
|
|
507
|
-
stopEdgeScroll()
|
|
508
|
-
scheduleClearFocus()
|
|
509
|
-
}}
|
|
510
|
-
>
|
|
420
|
+
<div className={styles.queue}>
|
|
511
421
|
{dots.length === 0 ? (
|
|
512
422
|
<div className={styles.empty}>{t('strip.empty')}</div>
|
|
513
423
|
) : (
|
|
514
|
-
|
|
424
|
+
<>
|
|
515
425
|
<span className={styles.count}>{dots.length}</span>
|
|
516
|
-
{
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
426
|
+
{scrollable ? (
|
|
427
|
+
<button
|
|
428
|
+
type="button"
|
|
429
|
+
className={`${styles.navBtn} ${styles.navUp}`}
|
|
430
|
+
aria-label={t('strip.up')}
|
|
431
|
+
style={{ visibility: canPageUp ? 'visible' : 'hidden' }}
|
|
432
|
+
onMouseEnter={cancelClearFocus}
|
|
433
|
+
onMouseLeave={scheduleClearFocus}
|
|
434
|
+
onClick={() => pageBy(-1)}
|
|
435
|
+
/>
|
|
436
|
+
) : null}
|
|
437
|
+
<div
|
|
438
|
+
ref={listRef}
|
|
439
|
+
className={scrollable ? `${styles.list} ${styles.listScrollable}` : styles.list}
|
|
440
|
+
onScroll={syncScroll}
|
|
441
|
+
onMouseLeave={scheduleClearFocus}
|
|
442
|
+
>
|
|
443
|
+
<div className={styles.dots}>
|
|
444
|
+
{dots.map((dot, index) => {
|
|
445
|
+
const isFocused = focus !== null && focus.key === dot.key
|
|
446
|
+
// Progressive magnification: selected largest, ±1 smaller, ±2
|
|
447
|
+
// smaller still, the rest base scale.
|
|
448
|
+
const tier = selectedIndex < 0 ? null : focusTier(index - selectedIndex)
|
|
449
|
+
const scale = jumpingKey === dot.key ? 1.6 : tier !== null ? focusScale(tier) : 1
|
|
450
|
+
const cls = [styles.dot]
|
|
451
|
+
if (isFocused) cls.push(styles.focused)
|
|
452
|
+
if (jumpingKey === dot.key) cls.push(styles.active)
|
|
453
|
+
return (
|
|
454
|
+
<button
|
|
455
|
+
key={dot.key}
|
|
456
|
+
className={cls.join(' ')}
|
|
457
|
+
style={{ transform: `scale(${scale})` }}
|
|
458
|
+
data-question-nav-focused={isFocused ? 'true' : undefined}
|
|
459
|
+
data-question-nav-index={index}
|
|
460
|
+
aria-label={dot.texts[0] ?? ''}
|
|
461
|
+
onMouseEnter={(e) => openFocus(dot, e.currentTarget)}
|
|
462
|
+
onClick={() => onJump(dot)}
|
|
463
|
+
/>
|
|
464
|
+
)
|
|
465
|
+
})}
|
|
466
|
+
</div>
|
|
467
|
+
</div>
|
|
468
|
+
{scrollable ? (
|
|
469
|
+
<button
|
|
470
|
+
type="button"
|
|
471
|
+
className={`${styles.navBtn} ${styles.navDown}`}
|
|
472
|
+
aria-label={t('strip.down')}
|
|
473
|
+
style={{ visibility: canPageDown ? 'visible' : 'hidden' }}
|
|
474
|
+
onMouseEnter={cancelClearFocus}
|
|
475
|
+
onMouseLeave={scheduleClearFocus}
|
|
476
|
+
onClick={() => pageBy(1)}
|
|
477
|
+
/>
|
|
478
|
+
) : null}
|
|
479
|
+
</>
|
|
539
480
|
)}
|
|
540
481
|
</div>
|
|
541
482
|
{hint !== null
|
package/src/client/locales.ts
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
*/
|
|
5
5
|
export const zh = {
|
|
6
6
|
'strip.empty': '本会话还没有提问',
|
|
7
|
+
'strip.up': '向上翻出 5 个提问圆点',
|
|
8
|
+
'strip.down': '向下翻出 5 个提问圆点',
|
|
7
9
|
'jump.inactive': '聊天视图未激活',
|
|
8
10
|
'jump.hidden': '目标无独立气泡,已定位到邻近内容',
|
|
9
11
|
'jump.notfound': '目标未加载或不存在(可能已压缩)',
|
|
@@ -17,6 +19,8 @@ export const zh = {
|
|
|
17
19
|
|
|
18
20
|
export const en = {
|
|
19
21
|
'strip.empty': 'No questions in this session yet',
|
|
22
|
+
'strip.up': 'Reveal 5 question dots above',
|
|
23
|
+
'strip.down': 'Reveal 5 question dots below',
|
|
20
24
|
'jump.inactive': 'Chat view is not active',
|
|
21
25
|
'jump.hidden': 'No dedicated bubble; landed on nearby content',
|
|
22
26
|
'jump.notfound': 'Target not loaded or missing (maybe compacted)',
|
|
@@ -21,19 +21,34 @@
|
|
|
21
21
|
left: auto;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
/*
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
.list {
|
|
24
|
+
/* The paging queue: the fixed column (count + ▲, then the scrollable dot band,
|
|
25
|
+
then ▼) that replaces the edge-fade auto-scroll. Clamped to 60% of the
|
|
26
|
+
conversation height and vertically centered (auto margins); when the queue
|
|
27
|
+
overflows, only the middle dot band scrolls — the count and triangles stay
|
|
28
|
+
pinned. The wrapper stays pass-through; the band and triangles re-enable
|
|
29
|
+
pointer events. */
|
|
30
|
+
.queue {
|
|
32
31
|
flex: 0 1 auto;
|
|
33
32
|
min-height: 0;
|
|
34
33
|
max-height: 60%;
|
|
35
34
|
margin: auto 0;
|
|
36
35
|
width: 100%;
|
|
36
|
+
display: flex;
|
|
37
|
+
flex-direction: column;
|
|
38
|
+
align-items: center;
|
|
39
|
+
gap: 6px;
|
|
40
|
+
box-sizing: border-box;
|
|
41
|
+
pointer-events: none;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/* The middle dot band: the only scrollable part of the queue. Scrolls from the
|
|
45
|
+
top (overflow collapses the auto margins to 0). The native scrollbar is
|
|
46
|
+
always hidden: this is a minimap paged by the ▲/▼ buttons and by
|
|
47
|
+
wheel/trackpad, and overflow is signalled by the edge fade below. */
|
|
48
|
+
.list {
|
|
49
|
+
flex: 1 1 auto;
|
|
50
|
+
min-height: 0;
|
|
51
|
+
width: 100%;
|
|
37
52
|
overflow-y: auto;
|
|
38
53
|
overflow-x: hidden;
|
|
39
54
|
display: flex;
|
|
@@ -50,18 +65,12 @@
|
|
|
50
65
|
display: none;
|
|
51
66
|
}
|
|
52
67
|
/* Overflowing band (applied from JS when scrollHeight > clientHeight): dots
|
|
53
|
-
fade out towards both edges
|
|
54
|
-
|
|
68
|
+
fade out towards both edges — a pure visual cue for "more beyond" (the
|
|
69
|
+
paging triangles do the actual scrolling). */
|
|
55
70
|
.listScrollable {
|
|
56
71
|
-webkit-mask-image: linear-gradient(to bottom, transparent, black 22px, black calc(100% - 22px), transparent);
|
|
57
72
|
mask-image: linear-gradient(to bottom, transparent, black 22px, black calc(100% - 22px), transparent);
|
|
58
73
|
}
|
|
59
|
-
.list > *:first-child {
|
|
60
|
-
margin-top: auto;
|
|
61
|
-
}
|
|
62
|
-
.list > *:last-child {
|
|
63
|
-
margin-bottom: auto;
|
|
64
|
-
}
|
|
65
74
|
|
|
66
75
|
.dot {
|
|
67
76
|
flex: none;
|
|
@@ -97,7 +106,7 @@
|
|
|
97
106
|
user-select: none;
|
|
98
107
|
}
|
|
99
108
|
|
|
100
|
-
/* The centered group:
|
|
109
|
+
/* The centered group: dot column, centered together (the list's auto
|
|
101
110
|
margins center it when short; it scrolls as a unit when tall). */
|
|
102
111
|
.dots {
|
|
103
112
|
display: flex;
|
|
@@ -106,6 +115,46 @@
|
|
|
106
115
|
gap: 6px;
|
|
107
116
|
}
|
|
108
117
|
|
|
118
|
+
/* Paging triangles (▲ above the dot queue, ▼ below), styled like the dots:
|
|
119
|
+
a plain triangle in the dot's quiet border color that lights up brand on
|
|
120
|
+
hover. Clicking pages the band by DOT_PAGE_ROWS dots. The triangle is a
|
|
121
|
+
pure CSS border triangle so it stays crisp at any DPI; the button box is a
|
|
122
|
+
slightly larger transparent hit target. */
|
|
123
|
+
.navBtn {
|
|
124
|
+
flex: none;
|
|
125
|
+
pointer-events: auto;
|
|
126
|
+
width: 14px;
|
|
127
|
+
height: 12px;
|
|
128
|
+
padding: 0;
|
|
129
|
+
border: none;
|
|
130
|
+
background: transparent;
|
|
131
|
+
cursor: pointer;
|
|
132
|
+
display: flex;
|
|
133
|
+
align-items: center;
|
|
134
|
+
justify-content: center;
|
|
135
|
+
}
|
|
136
|
+
.navBtn::before {
|
|
137
|
+
content: '';
|
|
138
|
+
display: block;
|
|
139
|
+
width: 0;
|
|
140
|
+
height: 0;
|
|
141
|
+
border-left: 5px solid transparent;
|
|
142
|
+
border-right: 5px solid transparent;
|
|
143
|
+
transition: border-color 120ms ease;
|
|
144
|
+
}
|
|
145
|
+
.navUp::before {
|
|
146
|
+
border-bottom: 7px solid var(--dsw-alias-border-l3);
|
|
147
|
+
}
|
|
148
|
+
.navDown::before {
|
|
149
|
+
border-top: 7px solid var(--dsw-alias-border-l3);
|
|
150
|
+
}
|
|
151
|
+
.navUp:hover::before {
|
|
152
|
+
border-bottom-color: var(--dsw-alias-brand-primary);
|
|
153
|
+
}
|
|
154
|
+
.navDown:hover::before {
|
|
155
|
+
border-top-color: var(--dsw-alias-brand-primary);
|
|
156
|
+
}
|
|
157
|
+
|
|
109
158
|
.empty {
|
|
110
159
|
padding: 10px 4px;
|
|
111
160
|
font-size: 11px;
|
package/src/core/focus.ts
CHANGED
|
@@ -75,58 +75,37 @@ export function magnificationWindow(total: number, selected: number, radius: num
|
|
|
75
75
|
return out
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
-
/** Height (px) of the dot band's top/bottom fade zones, which double as hover
|
|
79
|
-
* auto-scroll areas (slightly larger than the 22px CSS mask fade). */
|
|
80
|
-
export const EDGE_SCROLL_ZONE = 26
|
|
81
|
-
|
|
82
|
-
/** Clearance (px) kept around the focused dot when scrolling it into view, so
|
|
83
|
-
* its two magnified neighbors on each side stay inside the band's clear area
|
|
84
|
-
* (2 dot rows ≈ 28px + the 22px fade zone). */
|
|
85
|
-
export const FOCUS_NEIGHBOR_CLEARANCE = 50
|
|
86
|
-
|
|
87
78
|
/**
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
* already fully visible. Unlike unconditional centering this never shifts the
|
|
93
|
-
* band while the user browses dot-by-dot: only a clipped dot is scrolled.
|
|
79
|
+
* Paging model for the dot band. Overflowing dots are not auto-scrolled by
|
|
80
|
+
* hovering the edges or hovering a clipped dot: instead the user pages them
|
|
81
|
+
* with two triangle buttons (▲ above the dot queue, ▼ below it), each click
|
|
82
|
+
* revealing `DOT_PAGE_ROWS` hidden dots. Pure arithmetic — no React, no DOM.
|
|
94
83
|
*/
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
const next = dotTop < viewTop
|
|
108
|
-
? dotTop - margin
|
|
109
|
-
: dotTop + dotHeight + margin - clientHeight
|
|
110
|
-
return Math.max(0, Math.min(next, Math.max(0, scrollHeight - clientHeight)))
|
|
84
|
+
|
|
85
|
+
/** How many hidden dots one click of a paging triangle reveals. */
|
|
86
|
+
export const DOT_PAGE_ROWS = 5
|
|
87
|
+
|
|
88
|
+
/** Base dot diameter (px) and gap (px), matching the rail CSS — the paging
|
|
89
|
+
* step is expressed in whole dot rows, so it tracks the visible geometry. */
|
|
90
|
+
export const DOT_SIZE = 8
|
|
91
|
+
export const DOT_GAP = 6
|
|
92
|
+
|
|
93
|
+
/** Scroll step (px) for one paging click: `rows` whole dot rows. */
|
|
94
|
+
export function pageStep(dotSize: number = DOT_SIZE, gap: number = DOT_GAP, rows: number = DOT_PAGE_ROWS): number {
|
|
95
|
+
return rows * (dotSize + gap)
|
|
111
96
|
}
|
|
112
97
|
|
|
113
|
-
/**
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
98
|
+
/** Clamp a target scrollTop into the band's valid range (0..maxScroll). */
|
|
99
|
+
export function clampScrollTop(target: number, maxScroll: number): number {
|
|
100
|
+
return Math.max(0, Math.min(target, Math.max(0, maxScroll)))
|
|
101
|
+
}
|
|
117
102
|
|
|
118
|
-
/**
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
103
|
+
/** Whether hidden dots remain above the band (show ▲). */
|
|
104
|
+
export function canScrollAbove(scrollTop: number): boolean {
|
|
105
|
+
return scrollTop > 0
|
|
106
|
+
}
|
|
122
107
|
|
|
123
|
-
/**
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
* linearly from MIN to MAX so a shallow probe scrolls gently and pushing
|
|
127
|
-
* into the edge moves fast. The sign (direction) is applied by the caller.
|
|
128
|
-
*/
|
|
129
|
-
export function edgeScrollSpeed(ratio: number): number {
|
|
130
|
-
const r = Math.max(0, Math.min(1, ratio))
|
|
131
|
-
return EDGE_SCROLL_MIN_SPEED + (EDGE_SCROLL_MAX_SPEED - EDGE_SCROLL_MIN_SPEED) * r
|
|
108
|
+
/** Whether hidden dots remain below the band (show ▼). */
|
|
109
|
+
export function canScrollBelow(scrollTop: number, maxScroll: number): boolean {
|
|
110
|
+
return scrollTop < maxScroll
|
|
132
111
|
}
|