@lynn123411/dsh-model-submenu 0.3.10 → 0.3.12
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/DETAILS.md +8 -6
- package/README.md +1 -1
- package/lib/client.js +97 -153
- package/lib/client.js.map +1 -1
- package/package.json +1 -1
- package/src/client/EffortSlider.tsx +49 -27
- package/src/client/ModelOptions.module.css +29 -7
- package/src/client/ModelPicker.tsx +4 -86
- package/src/client/ModelRow.tsx +2 -12
- package/src/client/locales.ts +0 -6
- package/src/client/slider.ts +60 -13
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
*
|
|
4
4
|
* The Host's second menu cell is a button that drills into a list of levels.
|
|
5
5
|
* This slider sits inside that same cell, between the Host's label and value, so
|
|
6
|
-
* the levels are reachable without leaving the menu.
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* the levels are reachable without leaving the menu. The levels are discrete but
|
|
7
|
+
* the drag is not: the knob follows the pointer continuously, the level it
|
|
8
|
+
* reports is the detent nearest that position, and letting go eases the knob onto
|
|
9
|
+
* that detent and commits once. Keyboard arrows step and commit immediately.
|
|
9
10
|
*
|
|
10
11
|
* The fill takes the level's color, so the track and the two captions read as
|
|
11
12
|
* one signal: "off" is neutral, the levels above it walk a blue-to-violet ramp,
|
|
@@ -14,10 +15,10 @@
|
|
|
14
15
|
* "Provider default" is not a detent: it is a state, drawn as an empty track
|
|
15
16
|
* with a hollow knob, so it never reads as the lowest level.
|
|
16
17
|
*/
|
|
17
|
-
import { useRef, useState, type KeyboardEvent, type PointerEvent } from 'react';
|
|
18
|
+
import { useEffect, useRef, useState, type KeyboardEvent, type PointerEvent } from 'react';
|
|
18
19
|
import type { EffortChoice } from './selection.ts';
|
|
19
20
|
import { LEVEL_GRADIENT, isTopLevel, levelColor } from './level-color.ts';
|
|
20
|
-
import { knobOffset, offsetOf
|
|
21
|
+
import { detentAt, fillWidth, fractionAt, knobOffset, offsetOf } from './slider.ts';
|
|
21
22
|
import css from './ModelOptions.module.css';
|
|
22
23
|
|
|
23
24
|
/** Props of the slider: the levels, the one in force, and the commit path. */
|
|
@@ -49,22 +50,41 @@ export function EffortSlider({
|
|
|
49
50
|
steps, current, defaultActive, defaultIndex, defaultLabel, busy, label, onCommit,
|
|
50
51
|
}: EffortSliderProps) {
|
|
51
52
|
const trackRef = useRef<HTMLDivElement | null>(null);
|
|
52
|
-
// The drag
|
|
53
|
-
// arrive in one task, and the release must not read a render that never ran.
|
|
53
|
+
// The drag position lives in a ref as well as in state: a press and a release
|
|
54
|
+
// can arrive in one task, and the release must not read a render that never ran.
|
|
54
55
|
const dragRef = useRef<number | null>(null);
|
|
56
|
+
// While dragging this is where the pointer is along the travel, not a level:
|
|
57
|
+
// the knob has to move with the pointer, or the drag reads as a series of jumps.
|
|
55
58
|
const [drag, setDrag] = useState<number | null>(null);
|
|
59
|
+
// After a release that submits, the knob waits on the detent it settled on
|
|
60
|
+
// instead of springing back to the old level while the write is in flight.
|
|
61
|
+
const [hold, setHold] = useState<number | null>(null);
|
|
62
|
+
const dragging = drag !== null;
|
|
56
63
|
const resting = defaultActive ? defaultIndex : current;
|
|
57
|
-
const active = drag
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
64
|
+
const active = dragging ? detentAt(drag, steps.length) : resting;
|
|
65
|
+
const knobFraction = drag ?? hold ?? offsetOf(resting, steps.length);
|
|
66
|
+
// The color and the ticks follow the knob, not the level in force: during a
|
|
67
|
+
// drag, and while a commit is in flight, the knob has already gone ahead.
|
|
68
|
+
const shown = dragging || hold !== null ? detentAt(knobFraction, steps.length) : resting;
|
|
69
|
+
const beginDrag = (fraction: number | null): void => {
|
|
70
|
+
dragRef.current = fraction;
|
|
71
|
+
setDrag(fraction);
|
|
61
72
|
};
|
|
62
73
|
|
|
63
|
-
|
|
74
|
+
// The hold ends as soon as the write does, whether it landed on the new level
|
|
75
|
+
// or was rejected: the displayed level is the truth again either way.
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
if (!busy) setHold(null);
|
|
78
|
+
}, [busy]);
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
setHold(null);
|
|
81
|
+
}, [current, defaultActive]);
|
|
82
|
+
|
|
83
|
+
const fractionAtPointer = (clientX: number): number => {
|
|
64
84
|
const track = trackRef.current;
|
|
65
|
-
if (track === null) return
|
|
85
|
+
if (track === null) return dragRef.current ?? knobFraction;
|
|
66
86
|
const rect = track.getBoundingClientRect();
|
|
67
|
-
return
|
|
87
|
+
return fractionAt(clientX, rect.left, rect.width);
|
|
68
88
|
};
|
|
69
89
|
|
|
70
90
|
const onPointerDown = (event: PointerEvent<HTMLDivElement>): void => {
|
|
@@ -73,7 +93,8 @@ export function EffortSlider({
|
|
|
73
93
|
// also settle the cell and pull focus out of the slider.
|
|
74
94
|
event.preventDefault();
|
|
75
95
|
event.stopPropagation();
|
|
76
|
-
|
|
96
|
+
setHold(null);
|
|
97
|
+
beginDrag(fractionAtPointer(event.clientX));
|
|
77
98
|
try {
|
|
78
99
|
event.currentTarget.setPointerCapture(event.pointerId);
|
|
79
100
|
} catch {
|
|
@@ -84,18 +105,20 @@ export function EffortSlider({
|
|
|
84
105
|
|
|
85
106
|
const onPointerMove = (event: PointerEvent<HTMLDivElement>): void => {
|
|
86
107
|
if (dragRef.current === null || busy) return;
|
|
87
|
-
|
|
88
|
-
if (next !== dragRef.current) beginDrag(next);
|
|
108
|
+
beginDrag(fractionAtPointer(event.clientX));
|
|
89
109
|
};
|
|
90
110
|
|
|
91
111
|
const settle = (event: PointerEvent<HTMLDivElement>, commit: boolean): void => {
|
|
92
112
|
if (dragRef.current === null) return;
|
|
93
|
-
const next =
|
|
113
|
+
const next = detentAt(fractionAtPointer(event.clientX), steps.length);
|
|
94
114
|
beginDrag(null);
|
|
95
115
|
if (!commit) return;
|
|
96
116
|
// Leaving the provider default is a change even when the knob lands where
|
|
97
117
|
// the default already pointed.
|
|
98
|
-
if (next !== current || defaultActive)
|
|
118
|
+
if (next !== current || defaultActive) {
|
|
119
|
+
setHold(offsetOf(next, steps.length));
|
|
120
|
+
onCommit(next);
|
|
121
|
+
}
|
|
99
122
|
};
|
|
100
123
|
|
|
101
124
|
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>): void => {
|
|
@@ -117,13 +140,12 @@ export function EffortSlider({
|
|
|
117
140
|
if (next !== current || defaultActive) onCommit(next);
|
|
118
141
|
};
|
|
119
142
|
|
|
120
|
-
const knobFraction = offsetOf(active, steps.length);
|
|
121
143
|
const offset = knobOffset(knobFraction);
|
|
122
144
|
// The provider default fills nothing: the knob shows where it lands, the
|
|
123
145
|
// track stays empty so it never reads as a chosen level.
|
|
124
|
-
const idle = defaultActive &&
|
|
146
|
+
const idle = defaultActive && !dragging && hold === null;
|
|
125
147
|
const filled = idle ? 0 : knobFraction;
|
|
126
|
-
const fill = isTopLevel(
|
|
148
|
+
const fill = isTopLevel(shown, steps.length) ? LEVEL_GRADIENT : levelColor(shown, steps.length);
|
|
127
149
|
return <div
|
|
128
150
|
ref={trackRef}
|
|
129
151
|
className={css.slider}
|
|
@@ -133,11 +155,11 @@ export function EffortSlider({
|
|
|
133
155
|
aria-valuemin={0}
|
|
134
156
|
aria-valuemax={Math.max(0, steps.length - 1)}
|
|
135
157
|
aria-valuenow={active}
|
|
136
|
-
aria-valuetext={defaultActive &&
|
|
158
|
+
aria-valuetext={defaultActive && !dragging ? defaultLabel : steps[active]?.label}
|
|
137
159
|
aria-disabled={busy || undefined}
|
|
138
160
|
data-busy={busy ? '' : undefined}
|
|
139
|
-
data-dragging={
|
|
140
|
-
data-default={
|
|
161
|
+
data-dragging={dragging ? '' : undefined}
|
|
162
|
+
data-default={idle ? '' : undefined}
|
|
141
163
|
onPointerDown={onPointerDown}
|
|
142
164
|
onPointerMove={onPointerMove}
|
|
143
165
|
onPointerUp={(event) => { settle(event, true); }}
|
|
@@ -146,11 +168,11 @@ export function EffortSlider({
|
|
|
146
168
|
onClick={(event) => { event.stopPropagation(); }}
|
|
147
169
|
>
|
|
148
170
|
<div className={css.sliderTrack} aria-hidden="true">
|
|
149
|
-
<div className={css.sliderFill} style={{ width:
|
|
171
|
+
<div className={css.sliderFill} style={{ width: fillWidth(filled), background: fill }} />
|
|
150
172
|
{steps.map((step, index) => <span
|
|
151
173
|
key={step.key}
|
|
152
174
|
className={css.sliderTick}
|
|
153
|
-
data-on={index <=
|
|
175
|
+
data-on={index <= shown && !idle ? '' : undefined}
|
|
154
176
|
style={{ left: knobOffset(offsetOf(index, steps.length)) }}
|
|
155
177
|
/>)}
|
|
156
178
|
<div className={css.sliderKnob} style={{ left: offset }} />
|
|
@@ -41,17 +41,22 @@
|
|
|
41
41
|
cursor: default;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
/* The track is a capsule and the knob a larger circle riding on it: the knob
|
|
45
|
+
* overhangs at both ends, so it reads as a knob rather than as the track's own
|
|
46
|
+
* end cap. */
|
|
44
47
|
.sliderTrack {
|
|
45
48
|
position: relative;
|
|
46
49
|
width: 100%;
|
|
47
|
-
height:
|
|
50
|
+
height: 16px;
|
|
48
51
|
border-radius: 999px;
|
|
49
52
|
corner-shape: round;
|
|
50
53
|
background: var(--dsw-alias-border-l3);
|
|
51
54
|
}
|
|
52
55
|
|
|
53
|
-
/* The fill
|
|
54
|
-
*
|
|
56
|
+
/* The fill is the track's own shape cut at the knob: its right cap carries the
|
|
57
|
+
* track's radius and ends under the knob, so the color runs into the knob
|
|
58
|
+
* instead of stopping at a straight edge, and the last detent reaches the
|
|
59
|
+
* track's end. */
|
|
55
60
|
.sliderFill {
|
|
56
61
|
position: absolute;
|
|
57
62
|
top: 0;
|
|
@@ -59,8 +64,10 @@
|
|
|
59
64
|
left: 0;
|
|
60
65
|
border-radius: 999px;
|
|
61
66
|
corner-shape: round;
|
|
62
|
-
overflow: hidden;
|
|
63
67
|
pointer-events: none;
|
|
68
|
+
/* A level change eases the color to its new width; the drag turns this off so
|
|
69
|
+
* the edge stays under the pointer. */
|
|
70
|
+
transition: width 160ms cubic-bezier(0.2, 0, 0, 1);
|
|
64
71
|
}
|
|
65
72
|
|
|
66
73
|
/* One detent per level: the dot is the only thing that says the levels are
|
|
@@ -86,6 +93,9 @@
|
|
|
86
93
|
.sliderKnob {
|
|
87
94
|
position: absolute;
|
|
88
95
|
top: 50%;
|
|
96
|
+
/* The provider default draws a 1.5px ring; border-box keeps that ring inside
|
|
97
|
+
* the 20px circle the travel is computed for. */
|
|
98
|
+
box-sizing: border-box;
|
|
89
99
|
width: 20px;
|
|
90
100
|
height: 20px;
|
|
91
101
|
margin: -10px 0 0 -10px;
|
|
@@ -94,6 +104,21 @@
|
|
|
94
104
|
background: var(--dsw-alias-label-primary-foreground);
|
|
95
105
|
box-shadow: var(--dsw-elevation-soft);
|
|
96
106
|
pointer-events: none;
|
|
107
|
+
/* Letting go eases the knob onto the detent it settled on; the drag turns this
|
|
108
|
+
* off so the knob sits exactly under the pointer. */
|
|
109
|
+
transition: left 160ms cubic-bezier(0.2, 0, 0, 1);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
.slider[data-dragging] .sliderKnob,
|
|
113
|
+
.slider[data-dragging] .sliderFill {
|
|
114
|
+
transition: none;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
@media (prefers-reduced-motion: reduce) {
|
|
118
|
+
.sliderKnob,
|
|
119
|
+
.sliderFill {
|
|
120
|
+
transition: none;
|
|
121
|
+
}
|
|
97
122
|
}
|
|
98
123
|
|
|
99
124
|
/* Provider default: nothing is filled and the knob is a hollow ring, so the
|
|
@@ -367,6 +392,3 @@
|
|
|
367
392
|
flex-wrap: wrap;
|
|
368
393
|
}
|
|
369
394
|
|
|
370
|
-
.footerDot {
|
|
371
|
-
color: var(--dsw-alias-label-dimmed);
|
|
372
|
-
}
|
|
@@ -31,7 +31,6 @@ import {
|
|
|
31
31
|
searchRows,
|
|
32
32
|
type CatalogModel,
|
|
33
33
|
type ModelRow as CatalogRow,
|
|
34
|
-
type PickerTab,
|
|
35
34
|
} from './rows.ts'
|
|
36
35
|
import { Tag, isMobileDevice } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
37
36
|
import type { ModelFactsLookup } from './facts-source.ts'
|
|
@@ -85,8 +84,6 @@ export function ModelPicker({
|
|
|
85
84
|
const listRef = useRef<HTMLDivElement | null>(null)
|
|
86
85
|
const [query, setQuery] = useState('')
|
|
87
86
|
const [tab, setTab] = useState(ALL_TAB)
|
|
88
|
-
const [highlight, setHighlight] = useState(0)
|
|
89
|
-
const tabRefs = useRef(new Map<string, HTMLButtonElement>())
|
|
90
87
|
|
|
91
88
|
const tabs = useMemo(() => pickerTabs(state.groups, t('picker.all')), [state.groups, t])
|
|
92
89
|
const reason = useMemo(() => reasoningOf(state.groups), [state.groups])
|
|
@@ -106,7 +103,6 @@ export function ModelPicker({
|
|
|
106
103
|
if (!open) return
|
|
107
104
|
setQuery('')
|
|
108
105
|
setTab(ALL_TAB)
|
|
109
|
-
setHighlight(0)
|
|
110
106
|
if (listRef.current !== null) listRef.current.scrollTop = 0
|
|
111
107
|
// 粗指针设备不自动聚焦文本输入:移动端弹键盘会顶起布局,宿主座位也这样守。
|
|
112
108
|
const frame = requestAnimationFrame(() => {
|
|
@@ -115,34 +111,8 @@ export function ModelPicker({
|
|
|
115
111
|
return () => { cancelAnimationFrame(frame) }
|
|
116
112
|
}, [open])
|
|
117
113
|
|
|
118
|
-
useEffect(() => {
|
|
119
|
-
setHighlight((current) => visible.length === 0 ? 0 : Math.min(current, visible.length - 1))
|
|
120
|
-
}, [visible.length])
|
|
121
|
-
|
|
122
|
-
useEffect(() => {
|
|
123
|
-
const row = listRef.current?.querySelector<HTMLElement>(`[data-row-index="${highlight}"]`)
|
|
124
|
-
row?.scrollIntoView({ block: 'nearest' })
|
|
125
|
-
}, [highlight])
|
|
126
|
-
|
|
127
114
|
if (!open) return null
|
|
128
115
|
|
|
129
|
-
const activeRow = visible[highlight]
|
|
130
|
-
const activeId = activeRow === undefined ? undefined : `${id}-row-${highlight}`
|
|
131
|
-
const onSearchKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
|
132
|
-
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
|
133
|
-
event.preventDefault()
|
|
134
|
-
if (visible.length === 0) return
|
|
135
|
-
setHighlight((current) => {
|
|
136
|
-
const next = current + (event.key === 'ArrowDown' ? 1 : -1)
|
|
137
|
-
return (next + visible.length) % visible.length
|
|
138
|
-
})
|
|
139
|
-
return
|
|
140
|
-
}
|
|
141
|
-
if (event.key === 'Enter' && activeRow !== undefined && !busy) {
|
|
142
|
-
event.preventDefault()
|
|
143
|
-
pick(activeRow)
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
116
|
const pick = (row: CatalogRow) => {
|
|
147
117
|
const selection = planModelCommit(live, row.provider, row.model, reason, rememberedOf?.(row.provider, row.model))
|
|
148
118
|
if (selection === null) {
|
|
@@ -151,27 +121,6 @@ export function ModelPicker({
|
|
|
151
121
|
}
|
|
152
122
|
onPick(selection)
|
|
153
123
|
}
|
|
154
|
-
/** Move to another tab and hand it the keyboard, in one step. */
|
|
155
|
-
const goTab = (next: PickerTab | undefined) => {
|
|
156
|
-
if (next === undefined || next.id === tab) return
|
|
157
|
-
setTab(next.id)
|
|
158
|
-
setHighlight(0)
|
|
159
|
-
tabRefs.current.get(next.id)?.focus()
|
|
160
|
-
}
|
|
161
|
-
const onTabKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>) => {
|
|
162
|
-
const index = tabs.findIndex((candidate) => candidate.id === tab)
|
|
163
|
-
if (event.key === 'ArrowRight' || event.key === 'ArrowLeft') {
|
|
164
|
-
event.preventDefault()
|
|
165
|
-
const step = event.key === 'ArrowRight' ? 1 : -1
|
|
166
|
-
goTab(tabs[((index === -1 ? 0 : index) + step + tabs.length) % tabs.length])
|
|
167
|
-
return
|
|
168
|
-
}
|
|
169
|
-
if (event.key === 'Home' || event.key === 'End') {
|
|
170
|
-
event.preventDefault()
|
|
171
|
-
goTab(tabs[event.key === 'Home' ? 0 : tabs.length - 1])
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
|
|
175
124
|
const failures = state.failures.filter((failure) => tab === ALL_TAB || failure.id === tab)
|
|
176
125
|
const failed = state.error !== null && state.status === 'error'
|
|
177
126
|
const loading = state.status === 'loading'
|
|
@@ -192,13 +141,11 @@ export function ModelPicker({
|
|
|
192
141
|
role="searchbox"
|
|
193
142
|
aria-label={t('picker.search')}
|
|
194
143
|
aria-controls={`${id}-list`}
|
|
195
|
-
aria-activedescendant={activeId}
|
|
196
144
|
placeholder={t('picker.search')}
|
|
197
145
|
icon={<IconSearchOutlineRegular size={14} />}
|
|
198
146
|
value={query}
|
|
199
147
|
readOnly={busy}
|
|
200
148
|
onChange={(event) => { setQuery(event.target.value) }}
|
|
201
|
-
onKeyDown={onSearchKeyDown}
|
|
202
149
|
data-modal-autofocus=""
|
|
203
150
|
/>
|
|
204
151
|
</div>
|
|
@@ -206,10 +153,6 @@ export function ModelPicker({
|
|
|
206
153
|
{tabs.map((entry) => (
|
|
207
154
|
<button
|
|
208
155
|
key={entry.id}
|
|
209
|
-
ref={(node) => {
|
|
210
|
-
if (node === null) tabRefs.current.delete(entry.id)
|
|
211
|
-
else tabRefs.current.set(entry.id, node)
|
|
212
|
-
}}
|
|
213
156
|
type="button"
|
|
214
157
|
role="tab"
|
|
215
158
|
id={`${id}-tab-${entry.id}`}
|
|
@@ -217,29 +160,13 @@ export function ModelPicker({
|
|
|
217
160
|
aria-controls={`${id}-list`}
|
|
218
161
|
tabIndex={0}
|
|
219
162
|
className={css.pickerTab}
|
|
220
|
-
onClick={() => {
|
|
221
|
-
setTab(entry.id)
|
|
222
|
-
setHighlight(0)
|
|
223
|
-
}}
|
|
224
|
-
onKeyDown={onTabKeyDown}
|
|
225
|
-
onFocus={() => {
|
|
226
|
-
if (entry.id !== tab) {
|
|
227
|
-
setTab(entry.id)
|
|
228
|
-
setHighlight(0)
|
|
229
|
-
}
|
|
230
|
-
}}
|
|
163
|
+
onClick={() => { setTab(entry.id) }}
|
|
231
164
|
>
|
|
232
165
|
{entry.label}
|
|
233
166
|
</button>
|
|
234
167
|
))}
|
|
235
168
|
</div>
|
|
236
|
-
<div
|
|
237
|
-
ref={listRef}
|
|
238
|
-
id={`${id}-list`}
|
|
239
|
-
className={css.pickerList}
|
|
240
|
-
role="menu"
|
|
241
|
-
aria-label={t('picker.title')}
|
|
242
|
-
>
|
|
169
|
+
<div ref={listRef} id={`${id}-list`} className={css.pickerList}>
|
|
243
170
|
{loading && <div className={css.notice}>{t('status.loading')}</div>}
|
|
244
171
|
{failed && (
|
|
245
172
|
<div className={css.notice}>
|
|
@@ -256,8 +183,8 @@ export function ModelPicker({
|
|
|
256
183
|
{visible.length === 0 && !loading && !failed && (
|
|
257
184
|
<div className={css.notice}>{state.groups.length === 0 ? t('status.empty') : t('picker.searchEmpty')}</div>
|
|
258
185
|
)}
|
|
259
|
-
{visible.map((row
|
|
260
|
-
<div key={`${row.provider}/${row.model}`}
|
|
186
|
+
{visible.map((row) => (
|
|
187
|
+
<div key={`${row.provider}/${row.model}`}>
|
|
261
188
|
<ModelRow
|
|
262
189
|
name={row.entry.name}
|
|
263
190
|
badge={row.entry.badge === undefined
|
|
@@ -277,10 +204,7 @@ export function ModelPicker({
|
|
|
277
204
|
starLabel={isFavorite(favorites, row.provider, row.model)
|
|
278
205
|
? t('picker.unfavorite', { model: row.entry.name })
|
|
279
206
|
: t('picker.favorite', { model: row.entry.name })}
|
|
280
|
-
starTabStop={index === highlight}
|
|
281
|
-
rowTabIndex={-1}
|
|
282
207
|
onPick={() => { pick(row) }}
|
|
283
|
-
onFocusRow={() => { setHighlight(index) }}
|
|
284
208
|
onToggleFavorite={() => { onToggleFavorite(row.provider, row.model) }}
|
|
285
209
|
/>
|
|
286
210
|
</div>
|
|
@@ -288,12 +212,6 @@ export function ModelPicker({
|
|
|
288
212
|
</div>
|
|
289
213
|
<div className={css.pickerFooter}>
|
|
290
214
|
<span className={css.footerHint}>
|
|
291
|
-
<ShortcutKeys keys={['↑', '↓']} />
|
|
292
|
-
<span>{t('picker.hintNavigate')}</span>
|
|
293
|
-
<span className={css.footerDot}>·</span>
|
|
294
|
-
<ShortcutKeys keys={['Enter']} />
|
|
295
|
-
<span>{t('picker.hintSelect')}</span>
|
|
296
|
-
<span className={css.footerDot}>·</span>
|
|
297
215
|
<ShortcutKeys keys={['Esc']} />
|
|
298
216
|
<span>{t('picker.hintClose')}</span>
|
|
299
217
|
</span>
|
package/src/client/ModelRow.tsx
CHANGED
|
@@ -27,14 +27,8 @@ export interface ModelRowProps {
|
|
|
27
27
|
readonly meta?: string | undefined;
|
|
28
28
|
/** Localized accessible name for the star control. */
|
|
29
29
|
readonly starLabel?: string | undefined;
|
|
30
|
-
/** Whether the star is a Tab stop right now (the picker's roving highlight). */
|
|
31
|
-
readonly starTabStop?: boolean;
|
|
32
|
-
/** Tab order of the row itself; the picker keeps rows out of it so ↑/↓ own the walk. */
|
|
33
|
-
readonly rowTabIndex?: number;
|
|
34
30
|
/** Pick this row. */
|
|
35
31
|
readonly onPick: () => void;
|
|
36
|
-
/** The row took the keyboard, so its star becomes reachable. */
|
|
37
|
-
readonly onFocusRow?: (() => void) | undefined;
|
|
38
32
|
/** Toggle this row's favorite. */
|
|
39
33
|
readonly onToggleFavorite: () => void;
|
|
40
34
|
/** Extra row content, such as the picker's disabled reason. */
|
|
@@ -48,7 +42,7 @@ export interface ModelRowProps {
|
|
|
48
42
|
*/
|
|
49
43
|
export function ModelRow({
|
|
50
44
|
name, badge, selected, busy, favorited, meta, starLabel,
|
|
51
|
-
|
|
45
|
+
onPick, onToggleFavorite, children,
|
|
52
46
|
}: ModelRowProps) {
|
|
53
47
|
return <div
|
|
54
48
|
className={css.row}
|
|
@@ -57,14 +51,11 @@ export function ModelRow({
|
|
|
57
51
|
>
|
|
58
52
|
<button
|
|
59
53
|
type="button"
|
|
60
|
-
|
|
61
|
-
aria-checked={selected}
|
|
62
|
-
tabIndex={rowTabIndex}
|
|
54
|
+
aria-current={selected ? 'true' : undefined}
|
|
63
55
|
className={css.rowPick}
|
|
64
56
|
title={name}
|
|
65
57
|
disabled={busy}
|
|
66
58
|
onClick={onPick}
|
|
67
|
-
onFocus={onFocusRow}
|
|
68
59
|
>
|
|
69
60
|
<span className={css.rowCopy}>
|
|
70
61
|
<span className={css.rowName}>{name}</span>
|
|
@@ -77,7 +68,6 @@ export function ModelRow({
|
|
|
77
68
|
className={css.starButton}
|
|
78
69
|
aria-pressed={favorited}
|
|
79
70
|
aria-label={starLabel}
|
|
80
|
-
tabIndex={starTabStop ? 0 : -1}
|
|
81
71
|
disabled={busy}
|
|
82
72
|
onClick={onToggleFavorite}
|
|
83
73
|
>
|
package/src/client/locales.ts
CHANGED
|
@@ -19,8 +19,6 @@ export type ModelSubmenuLocaleKey =
|
|
|
19
19
|
| 'picker.all'
|
|
20
20
|
| 'picker.favorite'
|
|
21
21
|
| 'picker.unfavorite'
|
|
22
|
-
| 'picker.hintNavigate'
|
|
23
|
-
| 'picker.hintSelect'
|
|
24
22
|
| 'picker.hintClose'
|
|
25
23
|
| 'meta.effort'
|
|
26
24
|
| 'meta.images'
|
|
@@ -51,8 +49,6 @@ export const zh: Record<ModelSubmenuLocaleKey, string> = {
|
|
|
51
49
|
'picker.all': '全部',
|
|
52
50
|
'picker.favorite': '收藏 {model}',
|
|
53
51
|
'picker.unfavorite': '取消收藏 {model}',
|
|
54
|
-
'picker.hintNavigate': '导航',
|
|
55
|
-
'picker.hintSelect': '选择',
|
|
56
52
|
'picker.hintClose': '关闭',
|
|
57
53
|
'meta.effort': '思考',
|
|
58
54
|
'meta.images': '图片输入',
|
|
@@ -77,8 +73,6 @@ export const en: Record<ModelSubmenuLocaleKey, string> = {
|
|
|
77
73
|
'picker.all': 'All',
|
|
78
74
|
'picker.favorite': 'Favorite {model}',
|
|
79
75
|
'picker.unfavorite': 'Remove {model} from favorites',
|
|
80
|
-
'picker.hintNavigate': 'navigate',
|
|
81
|
-
'picker.hintSelect': 'select',
|
|
82
76
|
'picker.hintClose': 'close',
|
|
83
77
|
'meta.effort': 'Effort',
|
|
84
78
|
'meta.images': 'Images',
|
package/src/client/slider.ts
CHANGED
|
@@ -1,19 +1,29 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Geometry of the discrete effort slider.
|
|
3
3
|
*
|
|
4
|
-
* The track
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* The track is a capsule and the knob a slightly larger circle riding on it, so
|
|
5
|
+
* the knob overhangs the track instead of merging with its rounded end. The
|
|
6
|
+
* knob's centre travels between two points inset by the knob's radius, so the
|
|
7
|
+
* knob never runs past either end. A pointer position becomes a position along
|
|
8
|
+
* that travel; a position becomes the detent nearest it, or the CSS lengths that
|
|
9
|
+
* put the knob and the fill there.
|
|
8
10
|
*/
|
|
9
11
|
|
|
10
12
|
/**
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
|
|
13
|
+
* Track height in CSS pixels; the stylesheet keeps the same value. Half of it is
|
|
14
|
+
* the radius of the track's ends and of the fill's cap.
|
|
15
|
+
*/
|
|
16
|
+
export const TRACK_HEIGHT = 16;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Knob diameter in CSS pixels. It is larger than the track, so the knob reads as
|
|
20
|
+
* a knob riding on the track rather than as the track's own end cap.
|
|
14
21
|
*/
|
|
15
22
|
export const KNOB_SIZE = 20;
|
|
16
23
|
|
|
24
|
+
/** The knob's centre keeps its own radius away from either end of the track. */
|
|
25
|
+
const KNOB_RADIUS = KNOB_SIZE / 2;
|
|
26
|
+
|
|
17
27
|
/**
|
|
18
28
|
* @param index - detent index.
|
|
19
29
|
* @param count - number of detents.
|
|
@@ -26,26 +36,63 @@ export function offsetOf(index: number, count: number): number {
|
|
|
26
36
|
}
|
|
27
37
|
|
|
28
38
|
/**
|
|
39
|
+
* Where a pointer sits along the knob's travel. The drag reads this rather than a
|
|
40
|
+
* detent, so the knob follows the pointer instead of jumping a level at a time.
|
|
29
41
|
* @param clientX - pointer position in viewport coordinates.
|
|
30
42
|
* @param left - the track's left edge in viewport coordinates.
|
|
31
43
|
* @param width - the track's width in pixels.
|
|
32
|
-
* @
|
|
33
|
-
* @returns the detent nearest the pointer, clamped to the two ends.
|
|
44
|
+
* @returns the position as a fraction of the travel, clamped to 0 and 1.
|
|
34
45
|
*/
|
|
35
|
-
export function
|
|
36
|
-
if (count <= 1) return 0;
|
|
46
|
+
export function fractionAt(clientX: number, left: number, width: number): number {
|
|
37
47
|
const usable = width - KNOB_SIZE;
|
|
38
48
|
if (usable <= 0) return 0;
|
|
39
|
-
const fraction = (clientX - left -
|
|
49
|
+
const fraction = (clientX - left - KNOB_RADIUS) / usable;
|
|
50
|
+
return Math.min(Math.max(fraction, 0), 1);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* @param fraction - a knob position between 0 and 1.
|
|
55
|
+
* @param count - number of detents.
|
|
56
|
+
* @returns the detent nearest that position.
|
|
57
|
+
*/
|
|
58
|
+
export function detentAt(fraction: number, count: number): number {
|
|
59
|
+
if (count <= 1) return 0;
|
|
40
60
|
const clamped = Math.min(Math.max(fraction, 0), 1);
|
|
41
61
|
return Math.round(clamped * (count - 1));
|
|
42
62
|
}
|
|
43
63
|
|
|
64
|
+
/**
|
|
65
|
+
* @param clientX - pointer position in viewport coordinates.
|
|
66
|
+
* @param left - the track's left edge in viewport coordinates.
|
|
67
|
+
* @param width - the track's width in pixels.
|
|
68
|
+
* @param count - number of detents.
|
|
69
|
+
* @returns the detent nearest the pointer, clamped to the two ends.
|
|
70
|
+
*/
|
|
71
|
+
export function stepAt(clientX: number, left: number, width: number, count: number): number {
|
|
72
|
+
if (count <= 1) return 0;
|
|
73
|
+
return detentAt(fractionAt(clientX, left, width), count);
|
|
74
|
+
}
|
|
75
|
+
|
|
44
76
|
/**
|
|
45
77
|
* @param fraction - a knob position between 0 and 1.
|
|
46
78
|
* @returns the CSS length that puts the knob centre there, inset at both ends.
|
|
47
79
|
*/
|
|
48
80
|
export function knobOffset(fraction: number): string {
|
|
49
81
|
const clamped = Math.min(Math.max(fraction, 0), 1);
|
|
50
|
-
return `calc(${
|
|
82
|
+
return `calc(${KNOB_RADIUS}px + ${clamped} * (100% - ${KNOB_SIZE}px))`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Width of the colored fill for a knob position.
|
|
87
|
+
*
|
|
88
|
+
* The fill is the track's own shape cut at the knob: its right cap carries the
|
|
89
|
+
* track's radius and ends under the knob, so the color runs into the knob
|
|
90
|
+
* instead of stopping at a straight edge, and the last detent reaches the track's
|
|
91
|
+
* end rather than the knob's centre.
|
|
92
|
+
* @param fraction - a knob position between 0 and 1.
|
|
93
|
+
* @returns the CSS width of the fill.
|
|
94
|
+
*/
|
|
95
|
+
export function fillWidth(fraction: number): string {
|
|
96
|
+
const clamped = Math.min(Math.max(fraction, 0), 1);
|
|
97
|
+
return `calc(${TRACK_HEIGHT}px + ${clamped} * (100% - ${TRACK_HEIGHT}px))`;
|
|
51
98
|
}
|