@changfenhuang/dsh-genui 0.9.1
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/CHANGELOG.md +367 -0
- package/LICENSE +21 -0
- package/README.md +261 -0
- package/README.zh-CN.md +261 -0
- package/SKILL.md +115 -0
- package/cordis.patch.yml +9 -0
- package/demo-prompts.md +118 -0
- package/lib/assets/echarts.js +64 -0
- package/lib/assets/mermaid.js +3591 -0
- package/lib/assets/three.js +3844 -0
- package/lib/client.js +5 -0
- package/lib/index.js +1895 -0
- package/lib/invariant.js +19 -0
- package/lib/types/client/EChartNode.d.ts +4 -0
- package/lib/types/client/ErrorBoundary.d.ts +30 -0
- package/lib/types/client/GenuiBlock.d.ts +7 -0
- package/lib/types/client/PlotBlock.d.ts +44 -0
- package/lib/types/client/action-context.d.ts +7 -0
- package/lib/types/client/asset-echarts.d.ts +15 -0
- package/lib/types/client/asset-loader.d.ts +31 -0
- package/lib/types/client/asset-mermaid.d.ts +1 -0
- package/lib/types/client/asset-three.d.ts +1 -0
- package/lib/types/client/blocks/advanced.d.ts +104 -0
- package/lib/types/client/blocks/basic.d.ts +27 -0
- package/lib/types/client/blocks/charts.d.ts +38 -0
- package/lib/types/client/blocks/diagram/geometry.d.ts +73 -0
- package/lib/types/client/blocks/diagram/index.d.ts +5 -0
- package/lib/types/client/blocks/diagram/layout.d.ts +41 -0
- package/lib/types/client/blocks/diagram/theme.d.ts +35 -0
- package/lib/types/client/blocks/forms.d.ts +83 -0
- package/lib/types/client/blocks/render-node.d.ts +10 -0
- package/lib/types/client/blocks/state.d.ts +58 -0
- package/lib/types/client/dom-fence.d.ts +14 -0
- package/lib/types/client/echarts-lazy.d.ts +17 -0
- package/lib/types/client/fence-render.d.ts +58 -0
- package/lib/types/client/gallery.d.ts +9 -0
- package/lib/types/client/guard.d.ts +126 -0
- package/lib/types/client/index.d.ts +36 -0
- package/lib/types/client/interaction-store.d.ts +43 -0
- package/lib/types/client/mermaid-core.d.ts +8 -0
- package/lib/types/client/mermaid-lazy.d.ts +9 -0
- package/lib/types/client/mermaid-safe.d.ts +52 -0
- package/lib/types/client/panel-command.d.ts +29 -0
- package/lib/types/client/panel-store.d.ts +94 -0
- package/lib/types/client/panel.d.ts +15 -0
- package/lib/types/client/parse-partial.d.ts +37 -0
- package/lib/types/client/safe-math.d.ts +43 -0
- package/lib/types/client/scene3d-core.d.ts +21 -0
- package/lib/types/client/scene3d-lazy.d.ts +8 -0
- package/lib/types/client/spec.d.ts +610 -0
- package/lib/types/client/toolview.d.ts +7 -0
- package/lib/types/index.d.ts +1 -0
- package/lib/types/plugin/index.d.ts +25 -0
- package/lib/types/plugin/invariant.d.ts +15 -0
- package/lib/types/plugin/tool.d.ts +27 -0
- package/lib/types/shared/fence-repair.d.ts +67 -0
- package/package.json +121 -0
- package/src/client/EChartNode.tsx +242 -0
- package/src/client/ErrorBoundary.tsx +66 -0
- package/src/client/GenuiBlock.module.css +1263 -0
- package/src/client/GenuiBlock.tsx +166 -0
- package/src/client/PlotBlock.module.css +155 -0
- package/src/client/PlotBlock.tsx +431 -0
- package/src/client/action-context.ts +32 -0
- package/src/client/asset-echarts.ts +25 -0
- package/src/client/asset-loader.ts +79 -0
- package/src/client/asset-mermaid.ts +13 -0
- package/src/client/asset-three.ts +12 -0
- package/src/client/blocks/advanced.tsx +435 -0
- package/src/client/blocks/basic.tsx +116 -0
- package/src/client/blocks/charts.tsx +319 -0
- package/src/client/blocks/diagram/geometry.ts +151 -0
- package/src/client/blocks/diagram/index.tsx +284 -0
- package/src/client/blocks/diagram/layout.ts +199 -0
- package/src/client/blocks/diagram/theme.ts +108 -0
- package/src/client/blocks/forms.tsx +523 -0
- package/src/client/blocks/render-node.tsx +260 -0
- package/src/client/blocks/state.ts +61 -0
- package/src/client/dom-fence.tsx +641 -0
- package/src/client/echarts-lazy.ts +35 -0
- package/src/client/fence-render.tsx +230 -0
- package/src/client/gallery.ts +128 -0
- package/src/client/guard.ts +1450 -0
- package/src/client/index.tsx +195 -0
- package/src/client/interaction-store.ts +122 -0
- package/src/client/mermaid-core.ts +121 -0
- package/src/client/mermaid-lazy.ts +31 -0
- package/src/client/mermaid-safe.ts +122 -0
- package/src/client/panel-command.ts +119 -0
- package/src/client/panel-store.ts +471 -0
- package/src/client/panel.tsx +185 -0
- package/src/client/parse-partial.ts +162 -0
- package/src/client/safe-math.ts +270 -0
- package/src/client/scene3d-core.ts +192 -0
- package/src/client/scene3d-lazy.ts +26 -0
- package/src/client/spec.ts +767 -0
- package/src/client/toolview.tsx +66 -0
- package/src/css-modules.d.ts +5 -0
- package/src/index.ts +12 -0
- package/src/plugin/index.ts +173 -0
- package/src/plugin/invariant.ts +30 -0
- package/src/plugin/node-shims.d.ts +31 -0
- package/src/plugin/tool.ts +330 -0
- package/src/shared/fence-repair.ts +244 -0
- package/tsconfig.json +35 -0
- package/tsdown.config.ts +193 -0
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Form family: radio aggregation + submit grading, switch, slider, IME-safe
|
|
3
|
+
* input/select/textarea. All state flows through the shared AnswersState.
|
|
4
|
+
* @module @changfenhuang/dsh-genui/client/blocks/forms
|
|
5
|
+
*/
|
|
6
|
+
import { useEffect, useId, useRef, useState } from 'react'
|
|
7
|
+
import css from '../GenuiBlock.module.css'
|
|
8
|
+
import { GENUI_LIMITS } from '../guard.ts'
|
|
9
|
+
import type { AnswersState, GenuiBlockProps, QuestionMeta } from './state.ts'
|
|
10
|
+
import type { GenuiInput, GenuiRadio, GenuiSelect, GenuiSlider, GenuiSubmit, GenuiSwitch, GenuiTextarea } from '../spec.ts'
|
|
11
|
+
|
|
12
|
+
export function RadioNode({ node, onAction, answers }: {
|
|
13
|
+
node: GenuiRadio
|
|
14
|
+
onAction?: GenuiBlockProps['onAction']
|
|
15
|
+
answers?: AnswersState | undefined
|
|
16
|
+
}) {
|
|
17
|
+
const action = node.action
|
|
18
|
+
const group = node.group
|
|
19
|
+
const grouped = group !== undefined
|
|
20
|
+
const options = node.options.slice(0, GENUI_LIMITS.maxOptions)
|
|
21
|
+
// No default selection unless the model explicitly sets `selected` — a
|
|
22
|
+
// pre-checked first option silently swallows the user's "keep the default"
|
|
23
|
+
// answer (the registry only records real change events). A DURABLE answer
|
|
24
|
+
// (restored from localStorage) wins over both. The parent key includes the
|
|
25
|
+
// reset round, so 重新作答 remounts this radio with a clean selection —
|
|
26
|
+
// no sync effect needed.
|
|
27
|
+
const restoredIndex = group !== undefined && answers?.answers[group] !== undefined
|
|
28
|
+
? options.indexOf(answers!.answers[group]!)
|
|
29
|
+
: -1
|
|
30
|
+
const [selected, setSelected] = useState<number | null>(restoredIndex >= 0 ? restoredIndex : (node.selected ?? null))
|
|
31
|
+
const uid = useId()
|
|
32
|
+
const locked = grouped && answers?.locked === true
|
|
33
|
+
// Register question metadata for local grading (mount + when the question
|
|
34
|
+
// changes). `answers` is deliberately NOT a dep: the callback identity is
|
|
35
|
+
// stable and re-registering on every answers update is needless churn.
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
if (group === undefined) return
|
|
38
|
+
answers?.registerMeta(group, {
|
|
39
|
+
label: node.label ?? group,
|
|
40
|
+
options,
|
|
41
|
+
answer: node.answer,
|
|
42
|
+
explanation: node.explanation,
|
|
43
|
+
})
|
|
44
|
+
// A model-provided default selection IS the answer — but only when the
|
|
45
|
+
// group has no durable answer yet (a restored user choice must win).
|
|
46
|
+
if (node.selected !== undefined && options[node.selected] !== undefined && answers?.answers[group] === undefined) {
|
|
47
|
+
answers?.setAnswer(group, options[node.selected]!)
|
|
48
|
+
}
|
|
49
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
50
|
+
}, [group, node.label, node.answer, node.explanation, node.options, node.selected])
|
|
51
|
+
return (
|
|
52
|
+
<div className={css.fieldGroup} role="radiogroup" aria-label={node.label}>
|
|
53
|
+
{node.label !== undefined && <span className={css.fieldLabel}>{node.label}</span>}
|
|
54
|
+
{options.map((opt, i) => (
|
|
55
|
+
<label key={i} className={css.radio}>
|
|
56
|
+
<input
|
|
57
|
+
type="radio"
|
|
58
|
+
name={`genui-radio-${uid}`}
|
|
59
|
+
checked={selected === i}
|
|
60
|
+
disabled={locked}
|
|
61
|
+
onChange={() => {
|
|
62
|
+
setSelected(i)
|
|
63
|
+
if (grouped) {
|
|
64
|
+
// Aggregation mode: record, do NOT round-trip per click.
|
|
65
|
+
answers?.setAnswer(group, opt)
|
|
66
|
+
} else if (action !== undefined && onAction !== undefined) {
|
|
67
|
+
onAction(action, { type: 'radio', value: opt })
|
|
68
|
+
}
|
|
69
|
+
}}
|
|
70
|
+
/>
|
|
71
|
+
<span>{opt}</span>
|
|
72
|
+
</label>
|
|
73
|
+
))}
|
|
74
|
+
</div>
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Resolve a question's correct label from its metadata. */
|
|
79
|
+
export function correctLabelOf(m: QuestionMeta): string | undefined {
|
|
80
|
+
if (m.answer === undefined) return undefined
|
|
81
|
+
if (typeof m.answer === 'number') return m.options[m.answer]
|
|
82
|
+
return m.answer
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Submit: the "交卷" control of a grouped-radio block. LOCAL-FIRST (v2.6):
|
|
86
|
+
* when at least one question carries `answer` data the click grades IN PLACE
|
|
87
|
+
* — score, per-question right/wrong, explanations — with zero model round
|
|
88
|
+
* trip, and locks the questions until 重新作答 resets them. Only when NO
|
|
89
|
+
* question has answers does it fall back to firing ONE action
|
|
90
|
+
* (`{type:'submit', answers, total, answered}`). Disabled until the
|
|
91
|
+
* selection criteria are met (all listed groups answered, or ≥1 answer
|
|
92
|
+
* without a group list); the hint shows the progress. */
|
|
93
|
+
export function SubmitNode({ node, onAction, answers }: {
|
|
94
|
+
node: GenuiSubmit
|
|
95
|
+
onAction?: GenuiBlockProps['onAction']
|
|
96
|
+
answers?: AnswersState | undefined
|
|
97
|
+
}) {
|
|
98
|
+
const recorded = answers?.answers ?? {}
|
|
99
|
+
const fields = answers?.fields ?? {}
|
|
100
|
+
const meta = answers?.meta ?? {}
|
|
101
|
+
const expected = node.groups
|
|
102
|
+
// One shared notion of "filled fields" for answered/ready/payload: non-blank
|
|
103
|
+
// values only, secrets (password inputs) never collected into submit.
|
|
104
|
+
const filledFields = Object.fromEntries(
|
|
105
|
+
Object.entries(fields).filter(([id, v]) => v.trim() !== '' && !answers?.secretFields.has(id)),
|
|
106
|
+
)
|
|
107
|
+
// Without an explicit group list, the submit counts radio answers AND
|
|
108
|
+
// filled fields — a fields-only form (inputs with id + submit) enables
|
|
109
|
+
// once any field has a value.
|
|
110
|
+
const answered = expected === undefined
|
|
111
|
+
? Math.max(Object.keys(recorded).length, Object.keys(filledFields).length)
|
|
112
|
+
: expected.filter(g => recorded[g] !== undefined).length
|
|
113
|
+
const total = expected?.length ?? answered
|
|
114
|
+
const scope = expected ?? Object.keys(recorded)
|
|
115
|
+
// Local grading is possible when ANY in-scope question carries answers.
|
|
116
|
+
const canGradeLocally = scope.some(g => meta[g]?.answer !== undefined)
|
|
117
|
+
const submitted = answers?.locked === true
|
|
118
|
+
// Ready = enough answers AND the click can do something: either local
|
|
119
|
+
// grading, or a real action name + provider. A submit with neither is a
|
|
120
|
+
// display-only control — honest disabled affordance (action is optional:
|
|
121
|
+
// local grading needs no round trip).
|
|
122
|
+
const ready = answered > 0 && answered >= total
|
|
123
|
+
&& (canGradeLocally || (node.action !== undefined && onAction !== undefined))
|
|
124
|
+
|
|
125
|
+
if (submitted) {
|
|
126
|
+
// ── local grading result ──
|
|
127
|
+
const graded = scope.filter(g => recorded[g] !== undefined && meta[g]?.answer !== undefined)
|
|
128
|
+
const score = graded.filter(g => recorded[g] === correctLabelOf(meta[g]!)).length
|
|
129
|
+
return (
|
|
130
|
+
<div className={css.gradeWrap} data-genui-grade>
|
|
131
|
+
<div className={css.gradeScore}>
|
|
132
|
+
<span className={css.gradeScoreValue}>{score} / {graded.length}</span>
|
|
133
|
+
<span className={css.gradeScoreLabel}>得分{graded.length < scope.length ? `(${scope.length - graded.length} 题无答案未计分)` : ''}</span>
|
|
134
|
+
</div>
|
|
135
|
+
<div className={css.gradeList}>
|
|
136
|
+
{scope.map(g => {
|
|
137
|
+
const entry = recorded[g]
|
|
138
|
+
const m = meta[g]
|
|
139
|
+
if (entry === undefined || m === undefined) return null
|
|
140
|
+
const correct = correctLabelOf(m)
|
|
141
|
+
if (correct === undefined) {
|
|
142
|
+
return (
|
|
143
|
+
<div key={g} className={css.gradeItem}>
|
|
144
|
+
<span className={css.gradeQ}>{m.label}</span>
|
|
145
|
+
<span className={css.gradeAns}>你的答案:{entry}</span>
|
|
146
|
+
</div>
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
const isCorrect = entry === correct
|
|
150
|
+
return (
|
|
151
|
+
<div key={g} className={`${css.gradeItem} ${isCorrect ? css.gradeItemOk : css.gradeItemNo}`}>
|
|
152
|
+
<span className={css.gradeQ}>{m.label}</span>
|
|
153
|
+
<span className={css.gradeTag}>{isCorrect ? '✓' : '✗'}</span>
|
|
154
|
+
<span className={css.gradeAns}>
|
|
155
|
+
你的答案:{entry}
|
|
156
|
+
{!isCorrect && <span className={css.gradeRight}> 正确答案:{correct}</span>}
|
|
157
|
+
</span>
|
|
158
|
+
{m.explanation !== undefined && <span className={css.gradeExp}>{m.explanation}</span>}
|
|
159
|
+
</div>
|
|
160
|
+
)
|
|
161
|
+
})}
|
|
162
|
+
</div>
|
|
163
|
+
<button
|
|
164
|
+
type="button"
|
|
165
|
+
className={`${css.button} ${css.ghost} ${css.submit}`}
|
|
166
|
+
onClick={() => {
|
|
167
|
+
answers?.clear()
|
|
168
|
+
if (node.resetAction !== undefined && onAction !== undefined) {
|
|
169
|
+
onAction(node.resetAction, { type: 'submit-reset', groups: expected ?? Object.keys(recorded) })
|
|
170
|
+
}
|
|
171
|
+
}}
|
|
172
|
+
>
|
|
173
|
+
重新作答
|
|
174
|
+
</button>
|
|
175
|
+
</div>
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return (
|
|
180
|
+
<div className={css.submitRow}>
|
|
181
|
+
<button
|
|
182
|
+
type="button"
|
|
183
|
+
className={`${css.button} ${css.primary} ${css.submit}`}
|
|
184
|
+
disabled={!ready}
|
|
185
|
+
onClick={ready ? () => {
|
|
186
|
+
if (canGradeLocally) {
|
|
187
|
+
// Local grading: immediate in-place result, no model round trip.
|
|
188
|
+
answers?.setLocked(true)
|
|
189
|
+
} else if (node.action !== undefined && onAction !== undefined) {
|
|
190
|
+
// `ready` already guarantees this branch, but the narrow keeps
|
|
191
|
+
// the optional-action type honest.
|
|
192
|
+
onAction(node.action, {
|
|
193
|
+
type: 'submit',
|
|
194
|
+
answers: recorded,
|
|
195
|
+
...(Object.keys(filledFields).length > 0 ? { fields: filledFields } : {}),
|
|
196
|
+
total,
|
|
197
|
+
answered,
|
|
198
|
+
})
|
|
199
|
+
}
|
|
200
|
+
} : undefined}
|
|
201
|
+
>
|
|
202
|
+
{node.label}
|
|
203
|
+
</button>
|
|
204
|
+
{total > 0 && <span className={css.submitHint} aria-live="polite">已选 {answered}/{total}</span>}
|
|
205
|
+
</div>
|
|
206
|
+
)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Switch: toggle with local state. */
|
|
210
|
+
export function SwitchNode({ node, onAction }: { node: GenuiSwitch; onAction?: GenuiBlockProps['onAction'] }) {
|
|
211
|
+
const [on, setOn] = useState(node.checked === true)
|
|
212
|
+
const action = node.action
|
|
213
|
+
return (
|
|
214
|
+
<label className={css.switchRow}>
|
|
215
|
+
<span className={css.switchLabel}>{node.label}</span>
|
|
216
|
+
<button
|
|
217
|
+
type="button"
|
|
218
|
+
role="switch"
|
|
219
|
+
aria-checked={on}
|
|
220
|
+
className={`${css.switch} ${on ? css.switchOn : ''}`}
|
|
221
|
+
onClick={() => {
|
|
222
|
+
const next = !on
|
|
223
|
+
setOn(next)
|
|
224
|
+
if (action !== undefined && onAction !== undefined) onAction(action, { type: 'switch', checked: next })
|
|
225
|
+
}}
|
|
226
|
+
>
|
|
227
|
+
<span className={css.switchKnob} />
|
|
228
|
+
</button>
|
|
229
|
+
</label>
|
|
230
|
+
)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Slider: range input for numeric form values (v2.9). Field-aligned: with an
|
|
234
|
+
* `id` the value persists across refresh and joins the sibling submit's
|
|
235
|
+
* `fields` collection (stored as the numeric string); a model-provided
|
|
236
|
+
* default registers at mount; a restored durable value wins. Dragging fires
|
|
237
|
+
* the action (block-level debounce collapses the drag into one delivery). */
|
|
238
|
+
export function SliderNode({ node, onAction, answers }: {
|
|
239
|
+
node: GenuiSlider
|
|
240
|
+
onAction?: GenuiBlockProps['onAction']
|
|
241
|
+
answers?: AnswersState | undefined
|
|
242
|
+
}) {
|
|
243
|
+
const action = node.action
|
|
244
|
+
const id = node.id
|
|
245
|
+
const restored = id !== undefined && answers?.fields[id] !== undefined
|
|
246
|
+
? Number(answers!.fields[id]!)
|
|
247
|
+
: NaN
|
|
248
|
+
const initial = Number.isFinite(restored) ? restored : node.value ?? node.min ?? 0
|
|
249
|
+
const [value, setValue] = useState<number>(initial)
|
|
250
|
+
const mounted = useRef(false)
|
|
251
|
+
useEffect(() => {
|
|
252
|
+
if (mounted.current) return
|
|
253
|
+
mounted.current = true
|
|
254
|
+
if (id !== undefined) answers?.setField(id, String(initial))
|
|
255
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
256
|
+
}, [])
|
|
257
|
+
const send = (v: number): void => {
|
|
258
|
+
if (action !== undefined && onAction !== undefined) {
|
|
259
|
+
onAction(action, { type: 'slider', value: v, ...(id !== undefined ? { id } : {}) })
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return (
|
|
263
|
+
<label className={css.sliderRow}>
|
|
264
|
+
{node.label !== undefined && <span className={css.fieldLabel}>{node.label}</span>}
|
|
265
|
+
<input
|
|
266
|
+
type="range"
|
|
267
|
+
className={css.sliderInput}
|
|
268
|
+
min={node.min}
|
|
269
|
+
max={node.max}
|
|
270
|
+
step={node.step ?? 1}
|
|
271
|
+
value={value}
|
|
272
|
+
aria-label={node.label}
|
|
273
|
+
onChange={e => {
|
|
274
|
+
const v = Number(e.currentTarget.value)
|
|
275
|
+
setValue(v)
|
|
276
|
+
if (id !== undefined) answers?.setField(id, String(v))
|
|
277
|
+
send(v)
|
|
278
|
+
}}
|
|
279
|
+
/>
|
|
280
|
+
<span className={css.sliderValue}>{Math.round(value * 100) / 100}</span>
|
|
281
|
+
</label>
|
|
282
|
+
)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Reuse the DSH main input's three-layer IME protection (verified in the
|
|
286
|
+
* host InputBar): composition start arms a ref, composition end clears it
|
|
287
|
+
* 10ms later (Safari sends the closing keydown BEFORE compositionend), and
|
|
288
|
+
* every submit keydown re-checks the ref, the native `isComposing` flag,
|
|
289
|
+
* and `keyCode === 229`. A Chinese selection Enter must never submit. */
|
|
290
|
+
export function useImeComposing(): {
|
|
291
|
+
isComposing: () => boolean
|
|
292
|
+
onCompositionStart: () => void
|
|
293
|
+
onCompositionEnd: () => void
|
|
294
|
+
} {
|
|
295
|
+
const composing = useRef(false)
|
|
296
|
+
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
297
|
+
useEffect(() => {
|
|
298
|
+
return () => {
|
|
299
|
+
if (timer.current !== null) clearTimeout(timer.current)
|
|
300
|
+
}
|
|
301
|
+
}, [])
|
|
302
|
+
return {
|
|
303
|
+
isComposing: () => composing.current,
|
|
304
|
+
onCompositionStart: () => {
|
|
305
|
+
composing.current = true
|
|
306
|
+
if (timer.current !== null) {
|
|
307
|
+
clearTimeout(timer.current)
|
|
308
|
+
timer.current = null
|
|
309
|
+
}
|
|
310
|
+
},
|
|
311
|
+
onCompositionEnd: () => {
|
|
312
|
+
if (timer.current !== null) clearTimeout(timer.current)
|
|
313
|
+
timer.current = setTimeout(() => {
|
|
314
|
+
composing.current = false
|
|
315
|
+
}, 10)
|
|
316
|
+
},
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export function isImeSubmitKeydown(e: React.KeyboardEvent): boolean {
|
|
321
|
+
const native = e.nativeEvent
|
|
322
|
+
return native.isComposing === true || native.keyCode === 229
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Select: single choice from a dropdown, field-aligned (v2.8). With an `id`
|
|
326
|
+
* the chosen option persists across refresh and joins the sibling submit's
|
|
327
|
+
* `fields` collection; a model-provided `selected` default registers at
|
|
328
|
+
* mount; a restored durable value wins over both. Without any default a
|
|
329
|
+
* placeholder option shows — nothing is silently pre-registered (same
|
|
330
|
+
* philosophy as radio). */
|
|
331
|
+
export function SelectNode({ node, onAction, answers }: {
|
|
332
|
+
node: GenuiSelect
|
|
333
|
+
onAction?: GenuiBlockProps['onAction']
|
|
334
|
+
answers?: AnswersState | undefined
|
|
335
|
+
}) {
|
|
336
|
+
const action = node.action
|
|
337
|
+
const id = node.id
|
|
338
|
+
const options = node.options.slice(0, GENUI_LIMITS.maxOptions)
|
|
339
|
+
const restored = id !== undefined && answers?.fields[id] !== undefined
|
|
340
|
+
? options.indexOf(answers!.fields[id]!)
|
|
341
|
+
: -1
|
|
342
|
+
const defaultValue = restored >= 0
|
|
343
|
+
? options[restored]!
|
|
344
|
+
: node.selected !== undefined && options[node.selected] !== undefined
|
|
345
|
+
? options[node.selected]!
|
|
346
|
+
: null
|
|
347
|
+
const [value, setValue] = useState<string | null>(defaultValue)
|
|
348
|
+
// Field invariant: a spec-provided default registers at mount.
|
|
349
|
+
const mounted = useRef(false)
|
|
350
|
+
useEffect(() => {
|
|
351
|
+
if (mounted.current) return
|
|
352
|
+
mounted.current = true
|
|
353
|
+
if (id !== undefined && defaultValue !== null) {
|
|
354
|
+
answers?.setField(id, defaultValue)
|
|
355
|
+
}
|
|
356
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
357
|
+
}, [])
|
|
358
|
+
const send = (v: string): void => {
|
|
359
|
+
if (action !== undefined && onAction !== undefined) {
|
|
360
|
+
onAction(action, { type: 'select', value: v, ...(id !== undefined ? { id } : {}) })
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
return (
|
|
364
|
+
<label className={css.field}>
|
|
365
|
+
{node.label !== undefined && <span>{node.label}</span>}
|
|
366
|
+
<select
|
|
367
|
+
className={css.select}
|
|
368
|
+
value={value ?? ''}
|
|
369
|
+
onChange={e => {
|
|
370
|
+
const v = e.currentTarget.value
|
|
371
|
+
setValue(v)
|
|
372
|
+
if (id !== undefined) answers?.setField(id, v)
|
|
373
|
+
send(v)
|
|
374
|
+
}}
|
|
375
|
+
>
|
|
376
|
+
{value === null && <option value="" hidden disabled>请选择…</option>}
|
|
377
|
+
{options.map((o, i) => <option key={i} value={o}>{o}</option>)}
|
|
378
|
+
</select>
|
|
379
|
+
</label>
|
|
380
|
+
)
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** Input: single-line field. Controlled (value tracked for persistence and
|
|
384
|
+
* submit collection when `id` is set). With `action`: Enter submits
|
|
385
|
+
* immediately (`{type:'input', value, submit:true}`), blur sends too —
|
|
386
|
+
* the user never has to click elsewhere for the value to reach the model.
|
|
387
|
+
* Enter during IME composition never submits. `inputType: 'password'`
|
|
388
|
+
* stays masked; its value is never persisted and never joins submit
|
|
389
|
+
* collection (secrets stay out of localStorage), while its own `action`
|
|
390
|
+
* still delivers on explicit user submit. */
|
|
391
|
+
export function InputNode({ node, onAction, answers }: {
|
|
392
|
+
node: GenuiInput
|
|
393
|
+
onAction?: GenuiBlockProps['onAction']
|
|
394
|
+
answers?: AnswersState | undefined
|
|
395
|
+
}) {
|
|
396
|
+
const action = node.action
|
|
397
|
+
const id = node.id
|
|
398
|
+
const secret = node.inputType === 'password'
|
|
399
|
+
// Initial value: spec default, else durable state (restored after refresh).
|
|
400
|
+
// Secrets restore as blank: a password that survives a refresh would be a
|
|
401
|
+
// stored secret, which is exactly what the boundary forbids.
|
|
402
|
+
const [value, setValue] = useState<string>(() =>
|
|
403
|
+
secret ? '' : (node.value ?? (id !== undefined ? answers?.fields[id] ?? '' : '')))
|
|
404
|
+
// Last value actually DELIVERED to the model: blur only sends when the
|
|
405
|
+
// value changed since the last delivery (a focus-in/focus-out with no edit
|
|
406
|
+
// used to fire a pointless action round trip). Seeded with the mount value
|
|
407
|
+
// so the very first unedited blur also stays silent.
|
|
408
|
+
const lastSent = useRef<string | null>(value)
|
|
409
|
+
const send = (submit: boolean): void => {
|
|
410
|
+
if (action !== undefined && onAction !== undefined) {
|
|
411
|
+
lastSent.current = value
|
|
412
|
+
onAction(action, { type: 'input', value, ...(id !== undefined ? { id } : {}), ...(submit ? { submit: true } : {}) })
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
const ime = useImeComposing()
|
|
416
|
+
// Field invariant: a spec-provided non-blank default registers at mount.
|
|
417
|
+
const mounted = useRef(false)
|
|
418
|
+
useEffect(() => {
|
|
419
|
+
if (mounted.current) return
|
|
420
|
+
mounted.current = true
|
|
421
|
+
if (!secret && id !== undefined && node.value !== undefined && node.value.trim() !== '') {
|
|
422
|
+
answers?.setField(id, node.value)
|
|
423
|
+
}
|
|
424
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
425
|
+
}, [])
|
|
426
|
+
// Secret fields are filtered from persistence and submit collection.
|
|
427
|
+
useEffect(() => {
|
|
428
|
+
if (secret && id !== undefined) answers?.registerSecretField(id)
|
|
429
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
430
|
+
}, [secret, id])
|
|
431
|
+
return (
|
|
432
|
+
<label className={css.field}>
|
|
433
|
+
{node.label !== undefined && <span>{node.label}</span>}
|
|
434
|
+
<input
|
|
435
|
+
className={css.input}
|
|
436
|
+
type={node.inputType ?? 'text'}
|
|
437
|
+
placeholder={node.placeholder}
|
|
438
|
+
value={value}
|
|
439
|
+
onChange={e => {
|
|
440
|
+
const v = e.currentTarget.value
|
|
441
|
+
setValue(v)
|
|
442
|
+
if (id !== undefined) answers?.setField(id, v)
|
|
443
|
+
}}
|
|
444
|
+
onBlur={() => {
|
|
445
|
+
if (value !== lastSent.current) send(false)
|
|
446
|
+
}}
|
|
447
|
+
onCompositionStart={ime.onCompositionStart}
|
|
448
|
+
onCompositionEnd={ime.onCompositionEnd}
|
|
449
|
+
onKeyDown={e => {
|
|
450
|
+
if (e.key !== 'Enter') return
|
|
451
|
+
if (ime.isComposing() || isImeSubmitKeydown(e)) return
|
|
452
|
+
e.preventDefault()
|
|
453
|
+
send(true)
|
|
454
|
+
}}
|
|
455
|
+
/>
|
|
456
|
+
</label>
|
|
457
|
+
)
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** Textarea: multi-line input; with `action`, blurring sends the value and
|
|
461
|
+
* Ctrl/Cmd+Enter submits immediately. Controlled when `id` is set (durable
|
|
462
|
+
* value + submit collection). Ctrl/Cmd+Enter during IME composition never
|
|
463
|
+
* submits. */
|
|
464
|
+
export function TextareaNode({ node, onAction, answers }: {
|
|
465
|
+
node: GenuiTextarea
|
|
466
|
+
onAction?: GenuiBlockProps['onAction']
|
|
467
|
+
answers?: AnswersState | undefined
|
|
468
|
+
}) {
|
|
469
|
+
const action = node.action
|
|
470
|
+
const id = node.id
|
|
471
|
+
const [value, setValue] = useState<string>(() =>
|
|
472
|
+
node.value ?? (id !== undefined ? answers?.fields[id] ?? '' : ''))
|
|
473
|
+
// Last value delivered to the model: blur sends only on change. Seeded
|
|
474
|
+
// with the mount value so an unedited blur stays silent.
|
|
475
|
+
const lastSent = useRef<string | null>(value)
|
|
476
|
+
const send = (submit: boolean): void => {
|
|
477
|
+
if (action !== undefined && onAction !== undefined) {
|
|
478
|
+
lastSent.current = value
|
|
479
|
+
onAction(action, { type: 'textarea', value, ...(id !== undefined ? { id } : {}), ...(submit ? { submit: true } : {}) })
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
const ime = useImeComposing()
|
|
483
|
+
// Field invariant: a spec-provided non-blank default registers at mount.
|
|
484
|
+
const mounted = useRef(false)
|
|
485
|
+
useEffect(() => {
|
|
486
|
+
if (mounted.current) return
|
|
487
|
+
mounted.current = true
|
|
488
|
+
if (id !== undefined && node.value !== undefined && node.value.trim() !== '') {
|
|
489
|
+
answers?.setField(id, node.value)
|
|
490
|
+
}
|
|
491
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
492
|
+
}, [])
|
|
493
|
+
return (
|
|
494
|
+
<label className={css.field}>
|
|
495
|
+
{node.label !== undefined && <span>{node.label}</span>}
|
|
496
|
+
<textarea
|
|
497
|
+
className={css.textarea}
|
|
498
|
+
placeholder={node.placeholder}
|
|
499
|
+
rows={node.rows ?? 4}
|
|
500
|
+
value={value}
|
|
501
|
+
onChange={e => {
|
|
502
|
+
const v = e.currentTarget.value
|
|
503
|
+
setValue(v)
|
|
504
|
+
if (id !== undefined) answers?.setField(id, v)
|
|
505
|
+
}}
|
|
506
|
+
onBlur={() => {
|
|
507
|
+
if (value !== lastSent.current) send(false)
|
|
508
|
+
}}
|
|
509
|
+
onCompositionStart={ime.onCompositionStart}
|
|
510
|
+
onCompositionEnd={ime.onCompositionEnd}
|
|
511
|
+
onKeyDown={e => {
|
|
512
|
+
if (!(e.metaKey || e.ctrlKey) || e.key !== 'Enter') return
|
|
513
|
+
if (ime.isComposing() || isImeSubmitKeydown(e)) return
|
|
514
|
+
e.preventDefault()
|
|
515
|
+
send(true)
|
|
516
|
+
}}
|
|
517
|
+
/>
|
|
518
|
+
</label>
|
|
519
|
+
)
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/** Accordion: collapsible sections with local open state. Headings and
|
|
523
|
+
* bodies are wired via useId (`aria-controls`/`aria-labelledby`). */
|