@morlay/dsh-client-ui-primitives 0.0.2-alpha.7 → 0.0.2-alpha.9
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 +275 -13
- package/dist/client.cjs +4959 -1057
- package/dist/client.d.cts +1366 -16
- package/dist/client.d.mts +1365 -16
- package/dist/rolldown-runtime-EAsCxrDo.mjs +14 -0
- package/locale/en.json +1 -1
- package/locale/zh.json +1 -1
- package/package.json +28 -11
- package/src/client/index.ts +41 -2
- package/src/client/schema-form/Editor.tsx +777 -0
- package/src/client/schema-form/SchemaForm.tsx +77 -0
- package/src/client/schema-form/controller.ts +457 -0
- package/src/client/schema-form/draft.ts +487 -0
- package/src/client/schema-form/fields.tsx +100 -0
- package/src/client/schema-form/hints.ts +286 -0
- package/src/client/schema-form/index.ts +188 -0
- package/src/client/schema-form/labels.ts +30 -0
- package/src/client/schema-form/lines.ts +334 -0
- package/src/client/schema-form/locales.ts +128 -0
- package/src/client/schema-form/rows.ts +153 -0
- package/src/client/schema-form/schema-node.ts +812 -0
- package/src/client/schema-form/slot-contract.ts +156 -0
- package/src/client/schema-form/styles.ts +310 -0
- package/src/client/schema-form/testing/fake-describe.ts +45 -0
- package/src/client/schema-form/testing/fake-scope.ts +97 -0
- package/src/client/schema-form/value.tsx +146 -0
- package/src/client/settings-form/SettingsForm.styles.ts +58 -0
- package/src/client/settings-form/SettingsForm.tsx +72 -0
- package/src/client/settings-form/fields.styles.ts +158 -0
- package/src/client/settings-form/fields.tsx +194 -0
- package/src/client/settings-form/form-model.ts +421 -0
- package/src/client/settings-form/index.ts +19 -0
- package/src/client/styling/focus.ts +9 -0
- package/src/client/theme.generated.ts +802 -728
- package/cordis.patch.yml +0 -3
|
@@ -0,0 +1,777 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 行式编辑器:按 `editorLines` 排出的行画 JSON 结构视图——行号、折进、`key: value`、结构行、注释行、容器闭合行
|
|
3
|
+
* 后面的添加入口;值在行内点开编辑(Enter 提交 / Esc 撤销)。
|
|
4
|
+
*
|
|
5
|
+
* 缩进只作用在**行内容**上(行号列固定),添加输入与闭合括号同一行;行内控件用官方的 `Input` / `Menu` / `Button`,
|
|
6
|
+
* 与设置页其余部分同一套视觉与键盘行为。
|
|
7
|
+
*
|
|
8
|
+
* 字段槽仍然生效:命中时该字段**值**的位置换成注册方的组件,键名、注释、行号与行为按钮仍由这里画。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { useMemo, useState, type ReactNode } from "react";
|
|
12
|
+
import {
|
|
13
|
+
IconCheckOutlineRegular,
|
|
14
|
+
IconChevronDownOutlineRegular,
|
|
15
|
+
IconChevronRightOutlineRegular,
|
|
16
|
+
IconCloseOutlineRegular,
|
|
17
|
+
IconCopyOutlineRegular,
|
|
18
|
+
IconRefreshOutlineRegular,
|
|
19
|
+
IconTrashOutlineRegular,
|
|
20
|
+
Input,
|
|
21
|
+
Menu,
|
|
22
|
+
} from "@deepseek-ai/dsh-client-ui-primitives";
|
|
23
|
+
import type { SchemaFormFace, SchemaFormState } from "./controller.ts";
|
|
24
|
+
import { fieldKey } from "./controller.ts";
|
|
25
|
+
import {
|
|
26
|
+
containerShape,
|
|
27
|
+
editorLines,
|
|
28
|
+
type EditorLine,
|
|
29
|
+
type FoldState,
|
|
30
|
+
type VariantControl,
|
|
31
|
+
} from "./lines.ts";
|
|
32
|
+
import { parseFor } from "./fields.tsx";
|
|
33
|
+
import type { ResolveText } from "./labels.ts";
|
|
34
|
+
import { isMultiline, valueText } from "./value.tsx";
|
|
35
|
+
import type { SchemaFieldOwnerProps, SchemaFormTranslate } from "./slot-contract.ts";
|
|
36
|
+
import {
|
|
37
|
+
AddWrap,
|
|
38
|
+
CompactInput,
|
|
39
|
+
CompactTextField,
|
|
40
|
+
EditorRoot,
|
|
41
|
+
HoverActions,
|
|
42
|
+
ICON_SIZE,
|
|
43
|
+
LineBody,
|
|
44
|
+
LineComment,
|
|
45
|
+
LineFold,
|
|
46
|
+
LineFoldSpacer,
|
|
47
|
+
LineInvalid,
|
|
48
|
+
LineKey,
|
|
49
|
+
LineNumber,
|
|
50
|
+
LineRow,
|
|
51
|
+
LineToken,
|
|
52
|
+
LineValue,
|
|
53
|
+
ValueTrigger,
|
|
54
|
+
} from "./styles.ts";
|
|
55
|
+
|
|
56
|
+
/** 编辑器要的一切。 */
|
|
57
|
+
export interface EditorProps {
|
|
58
|
+
/** settings 命名空间(行 id)。 */
|
|
59
|
+
ns: string;
|
|
60
|
+
/** 页面读数。 */
|
|
61
|
+
state: SchemaFormState;
|
|
62
|
+
/** 动作面(写草稿、保存、丢弃)。 */
|
|
63
|
+
face: SchemaFormFace;
|
|
64
|
+
/** 本包字典。 */
|
|
65
|
+
t: SchemaFormTranslate;
|
|
66
|
+
/** `description` 的本地化。 */
|
|
67
|
+
resolveText: ResolveText;
|
|
68
|
+
/**
|
|
69
|
+
* 一个字段的值位置怎么画(字段槽入口:命中用注册方的组件,否则用本包的行内值)。
|
|
70
|
+
* @param owner - 字段上下文与动作。
|
|
71
|
+
* @returns 行内值的节点。
|
|
72
|
+
*/
|
|
73
|
+
renderField: (owner: SchemaFieldOwnerProps) => ReactNode;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* 画一行的配置。
|
|
78
|
+
* @param props - 读数、动作、字典与字段槽入口。
|
|
79
|
+
* @returns 行式编辑器。
|
|
80
|
+
*/
|
|
81
|
+
export function Editor(props: EditorProps): ReactNode {
|
|
82
|
+
const { ns, state, face, t, resolveText, renderField } = props;
|
|
83
|
+
const [toggled, setToggled] = useState<ReadonlySet<string>>(() => new Set());
|
|
84
|
+
const [editing, setEditing] = useState<string | undefined>(undefined);
|
|
85
|
+
const [selected, setSelected] = useState<string | undefined>(undefined);
|
|
86
|
+
// 还没值的格子默认就是输入框;点过「取消」的那些先收起来,点值可以再进来。
|
|
87
|
+
const [dismissed, setDismissed] = useState<ReadonlySet<string>>(() => new Set());
|
|
88
|
+
const fold = useFold(state, toggled, setToggled);
|
|
89
|
+
const lines = useMemo(
|
|
90
|
+
() => editorLines(state, fold, resolveText, t),
|
|
91
|
+
[state, fold, resolveText, t],
|
|
92
|
+
);
|
|
93
|
+
const disabled = !state.writable || !state.available;
|
|
94
|
+
return (
|
|
95
|
+
<EditorRoot data-editor="schema-form">
|
|
96
|
+
{lines.map((line, index) => (
|
|
97
|
+
<Row
|
|
98
|
+
key={`${line.kind}:${fieldKey(line.path)}:${String(index)}`}
|
|
99
|
+
line={line}
|
|
100
|
+
n={index + 1}
|
|
101
|
+
ns={ns}
|
|
102
|
+
state={state}
|
|
103
|
+
face={face}
|
|
104
|
+
fold={fold}
|
|
105
|
+
t={t}
|
|
106
|
+
resolveText={resolveText}
|
|
107
|
+
disabled={disabled}
|
|
108
|
+
editing={editing}
|
|
109
|
+
selected={selected}
|
|
110
|
+
onEdit={(key) => {
|
|
111
|
+
setEditing(key);
|
|
112
|
+
if (key !== undefined) {
|
|
113
|
+
setDismissed((previous) => {
|
|
114
|
+
const next = new Set(previous);
|
|
115
|
+
next.delete(key);
|
|
116
|
+
return next;
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
}}
|
|
120
|
+
// 刚加进来的那一行直接进编辑态:用户从候选里选完就能打字,不用再点一次那个空值。
|
|
121
|
+
onAdded={(path) => {
|
|
122
|
+
setEditing(fieldKey(path));
|
|
123
|
+
}}
|
|
124
|
+
dismissed={dismissed}
|
|
125
|
+
onDismiss={(key) => {
|
|
126
|
+
setDismissed((previous) => new Set(previous).add(key));
|
|
127
|
+
}}
|
|
128
|
+
onSelect={(path) => {
|
|
129
|
+
setSelected(fieldKey(path));
|
|
130
|
+
}}
|
|
131
|
+
renderField={renderField}
|
|
132
|
+
/>
|
|
133
|
+
))}
|
|
134
|
+
</EditorRoot>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** 折叠状态:记「用户切换过的路径」,开合 = 切换过就反转 schema 的默认值。 */
|
|
139
|
+
function useFold(
|
|
140
|
+
state: SchemaFormState,
|
|
141
|
+
toggled: ReadonlySet<string>,
|
|
142
|
+
setToggled: (next: ReadonlySet<string>) => void,
|
|
143
|
+
): FoldState {
|
|
144
|
+
const defaults = useMemo(() => {
|
|
145
|
+
const collapsed = new Set<string>();
|
|
146
|
+
for (const item of state.walked) {
|
|
147
|
+
const value = state.fields.get(fieldKey(item.path))?.value;
|
|
148
|
+
if (item.node.meta.collapse && containerShape(item.node, value) !== undefined) {
|
|
149
|
+
collapsed.add(fieldKey(item.path));
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return collapsed;
|
|
153
|
+
}, [state.walked, state.fields]);
|
|
154
|
+
return useMemo(
|
|
155
|
+
() => ({
|
|
156
|
+
collapsed: (path) => {
|
|
157
|
+
const key = fieldKey(path);
|
|
158
|
+
return toggled.has(key) !== defaults.has(key);
|
|
159
|
+
},
|
|
160
|
+
toggle: (path) => {
|
|
161
|
+
const key = fieldKey(path);
|
|
162
|
+
const next = new Set(toggled);
|
|
163
|
+
if (next.has(key)) next.delete(key);
|
|
164
|
+
else next.add(key);
|
|
165
|
+
setToggled(next);
|
|
166
|
+
},
|
|
167
|
+
}),
|
|
168
|
+
[defaults, setToggled, toggled],
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** 一行的渲染分派。 */
|
|
173
|
+
function Row({
|
|
174
|
+
line,
|
|
175
|
+
n,
|
|
176
|
+
ns,
|
|
177
|
+
state,
|
|
178
|
+
face,
|
|
179
|
+
fold,
|
|
180
|
+
t,
|
|
181
|
+
resolveText,
|
|
182
|
+
disabled,
|
|
183
|
+
editing,
|
|
184
|
+
selected,
|
|
185
|
+
dismissed,
|
|
186
|
+
onEdit,
|
|
187
|
+
onAdded,
|
|
188
|
+
onDismiss,
|
|
189
|
+
onSelect,
|
|
190
|
+
renderField,
|
|
191
|
+
}: {
|
|
192
|
+
line: EditorLine;
|
|
193
|
+
n: number;
|
|
194
|
+
ns: string;
|
|
195
|
+
state: SchemaFormState;
|
|
196
|
+
face: SchemaFormFace;
|
|
197
|
+
fold: FoldState;
|
|
198
|
+
t: SchemaFormTranslate;
|
|
199
|
+
resolveText: ResolveText;
|
|
200
|
+
disabled: boolean;
|
|
201
|
+
editing: string | undefined;
|
|
202
|
+
selected: string | undefined;
|
|
203
|
+
dismissed: ReadonlySet<string>;
|
|
204
|
+
onEdit: (key: string | undefined) => void;
|
|
205
|
+
onAdded: (path: readonly string[]) => void;
|
|
206
|
+
onDismiss: (key: string) => void;
|
|
207
|
+
onSelect: (path: readonly string[]) => void;
|
|
208
|
+
renderField: (owner: SchemaFieldOwnerProps) => ReactNode;
|
|
209
|
+
}): ReactNode {
|
|
210
|
+
const key = fieldKey(line.path);
|
|
211
|
+
const indent = { paddingLeft: `${String(line.depth * 14)}px` };
|
|
212
|
+
const body =
|
|
213
|
+
line.kind === "comment" ? (
|
|
214
|
+
<>
|
|
215
|
+
<LineFoldSpacer data-role="fold" />
|
|
216
|
+
{/* 这一行有问题时注释位装的就是那条消息:红字,别整行铺红。 */}
|
|
217
|
+
{line.invalid ? (
|
|
218
|
+
<LineInvalid title={line.text}>{`// ${line.text}`}</LineInvalid>
|
|
219
|
+
) : (
|
|
220
|
+
<LineComment title={line.text}>{`// ${line.text}`}</LineComment>
|
|
221
|
+
)}
|
|
222
|
+
</>
|
|
223
|
+
) : line.kind === "open" ? (
|
|
224
|
+
<>
|
|
225
|
+
<LineFold
|
|
226
|
+
data-role="fold"
|
|
227
|
+
type="button"
|
|
228
|
+
aria-expanded={!line.collapsed}
|
|
229
|
+
aria-label={t("toggleGroup")}
|
|
230
|
+
onClick={() => {
|
|
231
|
+
fold.toggle(line.path);
|
|
232
|
+
}}
|
|
233
|
+
>
|
|
234
|
+
{line.collapsed ? (
|
|
235
|
+
<IconChevronRightOutlineRegular size={ICON_SIZE} />
|
|
236
|
+
) : (
|
|
237
|
+
<IconChevronDownOutlineRegular size={ICON_SIZE} />
|
|
238
|
+
)}
|
|
239
|
+
</LineFold>
|
|
240
|
+
<LinePrefix line={line} face={face} t={t} disabled={disabled} />
|
|
241
|
+
<LineToken>
|
|
242
|
+
{line.shape === "object" ? (line.collapsed ? "{…}" : "{") : line.collapsed ? "[…]" : "["}
|
|
243
|
+
</LineToken>
|
|
244
|
+
<VariantSelect
|
|
245
|
+
control={line.variants}
|
|
246
|
+
path={line.path}
|
|
247
|
+
face={face}
|
|
248
|
+
disabled={disabled}
|
|
249
|
+
t={t}
|
|
250
|
+
/>
|
|
251
|
+
</>
|
|
252
|
+
) : line.kind === "close" ? (
|
|
253
|
+
<>
|
|
254
|
+
<LineFoldSpacer data-role="fold" />
|
|
255
|
+
<LineToken>{line.shape === "object" ? "}" : "]"}</LineToken>
|
|
256
|
+
<AddLine
|
|
257
|
+
line={line}
|
|
258
|
+
state={state}
|
|
259
|
+
face={face}
|
|
260
|
+
t={t}
|
|
261
|
+
resolveText={resolveText}
|
|
262
|
+
disabled={disabled}
|
|
263
|
+
onAdded={onAdded}
|
|
264
|
+
/>
|
|
265
|
+
</>
|
|
266
|
+
) : (
|
|
267
|
+
<FieldLine
|
|
268
|
+
line={line}
|
|
269
|
+
ns={ns}
|
|
270
|
+
state={state}
|
|
271
|
+
face={face}
|
|
272
|
+
t={t}
|
|
273
|
+
disabled={disabled}
|
|
274
|
+
editing={editing}
|
|
275
|
+
dismissed={dismissed}
|
|
276
|
+
onEdit={onEdit}
|
|
277
|
+
onDismiss={onDismiss}
|
|
278
|
+
renderField={renderField}
|
|
279
|
+
/>
|
|
280
|
+
);
|
|
281
|
+
return (
|
|
282
|
+
<LineRow
|
|
283
|
+
data-line={line.kind}
|
|
284
|
+
data-field-path={line.path.join(".")}
|
|
285
|
+
data-selected={selected === key ? "true" : undefined}
|
|
286
|
+
data-dirty={line.kind === "field" && line.field.staged ? "true" : undefined}
|
|
287
|
+
data-overridden={
|
|
288
|
+
line.kind === "field" && line.field.overridden && !line.field.staged ? "true" : undefined
|
|
289
|
+
}
|
|
290
|
+
data-invalid={
|
|
291
|
+
line.kind === "field" && (line.field.invalid !== undefined || state.invalidAt.has(key))
|
|
292
|
+
? "true"
|
|
293
|
+
: undefined
|
|
294
|
+
}
|
|
295
|
+
>
|
|
296
|
+
<LineNumber
|
|
297
|
+
data-role="number"
|
|
298
|
+
onClick={() => {
|
|
299
|
+
onSelect(line.path);
|
|
300
|
+
}}
|
|
301
|
+
>
|
|
302
|
+
{n}
|
|
303
|
+
</LineNumber>
|
|
304
|
+
<LineBody data-role="body" style={indent}>
|
|
305
|
+
{body}
|
|
306
|
+
</LineBody>
|
|
307
|
+
</LineRow>
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** 字段行:`key: value`(数组成员的键名是下标,淡一些)。 */
|
|
312
|
+
function FieldLine({
|
|
313
|
+
line,
|
|
314
|
+
ns,
|
|
315
|
+
state,
|
|
316
|
+
face,
|
|
317
|
+
t,
|
|
318
|
+
disabled,
|
|
319
|
+
editing,
|
|
320
|
+
dismissed,
|
|
321
|
+
onEdit,
|
|
322
|
+
onDismiss,
|
|
323
|
+
renderField,
|
|
324
|
+
}: {
|
|
325
|
+
line: Extract<EditorLine, { kind: "field" }>;
|
|
326
|
+
ns: string;
|
|
327
|
+
state: SchemaFormState;
|
|
328
|
+
face: SchemaFormFace;
|
|
329
|
+
t: SchemaFormTranslate;
|
|
330
|
+
disabled: boolean;
|
|
331
|
+
editing: string | undefined;
|
|
332
|
+
dismissed: ReadonlySet<string>;
|
|
333
|
+
onEdit: (key: string | undefined) => void;
|
|
334
|
+
onDismiss: (key: string) => void;
|
|
335
|
+
renderField: (owner: SchemaFieldOwnerProps) => ReactNode;
|
|
336
|
+
}): ReactNode {
|
|
337
|
+
const key = fieldKey(line.path);
|
|
338
|
+
const member = line.member;
|
|
339
|
+
const owner: SchemaFieldOwnerProps = {
|
|
340
|
+
ns,
|
|
341
|
+
path: line.path,
|
|
342
|
+
node: line.node,
|
|
343
|
+
label: line.node.label ?? member?.key ?? line.node.key,
|
|
344
|
+
hint: undefined,
|
|
345
|
+
value: line.field.value,
|
|
346
|
+
options: state.options.get(key),
|
|
347
|
+
text: line.field.text,
|
|
348
|
+
secretConfigured: state.secrets.get(key) === true,
|
|
349
|
+
depth: line.depth,
|
|
350
|
+
overridden: line.field.overridden,
|
|
351
|
+
invalid: line.field.invalid,
|
|
352
|
+
disabled,
|
|
353
|
+
t,
|
|
354
|
+
actions: face,
|
|
355
|
+
group: undefined,
|
|
356
|
+
member,
|
|
357
|
+
onChange: (value) => {
|
|
358
|
+
face.set(line.path, value);
|
|
359
|
+
},
|
|
360
|
+
onEditText: (text, parse) => {
|
|
361
|
+
// 文本按**这一行 schema 的类型**解析(number 就是数字、boolean 认 true/false、any 走 JSON)——
|
|
362
|
+
// 行内编辑与字段槽走的必须是同一条规则,否则 `busyTimeout` 会被存成 `"3000"`。
|
|
363
|
+
face.setText(line.path, text, parse ?? parseFor(line.node, t));
|
|
364
|
+
},
|
|
365
|
+
onReset: () => {
|
|
366
|
+
face.clear(line.path);
|
|
367
|
+
},
|
|
368
|
+
};
|
|
369
|
+
// 不可编辑的几种来源:部署只读(`disabled`)、schema 的只读原因(transform / function / 构造器)、
|
|
370
|
+
// 固定值(`const`,改不了;判别式 union 的标签行靠变体切换改),以及给成选择器的字段——它在值上直接选。
|
|
371
|
+
const readOnly = line.node.meta.disabled || line.node.readOnly !== null;
|
|
372
|
+
const editable =
|
|
373
|
+
!disabled &&
|
|
374
|
+
!readOnly &&
|
|
375
|
+
line.node.type !== "const" &&
|
|
376
|
+
owner.options === undefined &&
|
|
377
|
+
!line.variantsStandIn;
|
|
378
|
+
// 还没有值的位子(`null` / `undefined`)直接就是输入框:用户不用先点一下那个空值;点过取消的先收起来。
|
|
379
|
+
const missing = line.field.value === null || line.field.value === undefined;
|
|
380
|
+
const isEditing = editable && (editing === key || (missing && !dismissed.has(key)));
|
|
381
|
+
const text = line.field.text ?? valueText(line.field.value);
|
|
382
|
+
const multiline = isMultiline(line.field.value) || /[\r\n]/.test(text);
|
|
383
|
+
return (
|
|
384
|
+
<>
|
|
385
|
+
<LineFoldSpacer data-role="fold" />
|
|
386
|
+
<LineKey
|
|
387
|
+
data-role="key"
|
|
388
|
+
data-index={member?.index === undefined ? undefined : "true"}
|
|
389
|
+
title={member?.key ?? line.node.key}
|
|
390
|
+
>
|
|
391
|
+
{member?.key ?? line.node.key}
|
|
392
|
+
</LineKey>
|
|
393
|
+
<LineToken>{member?.index === undefined ? ": " : " "}</LineToken>
|
|
394
|
+
{isEditing ? (
|
|
395
|
+
// 带换行的值给多行输入(Enter 换行、Esc 撤销),其余是一行的官方输入框。
|
|
396
|
+
multiline ? (
|
|
397
|
+
<CompactTextField>
|
|
398
|
+
<textarea
|
|
399
|
+
autoFocus={!missing}
|
|
400
|
+
rows={1}
|
|
401
|
+
aria-label={owner.label}
|
|
402
|
+
value={text}
|
|
403
|
+
onChange={(event) => {
|
|
404
|
+
owner.onEditText(event.target.value);
|
|
405
|
+
}}
|
|
406
|
+
onKeyDown={(event) => {
|
|
407
|
+
if (event.key === "Escape") {
|
|
408
|
+
face.revert(line.path);
|
|
409
|
+
onEdit(undefined);
|
|
410
|
+
}
|
|
411
|
+
}}
|
|
412
|
+
/>
|
|
413
|
+
</CompactTextField>
|
|
414
|
+
) : (
|
|
415
|
+
<CompactInput>
|
|
416
|
+
<Input
|
|
417
|
+
autoFocus={!missing}
|
|
418
|
+
type="text"
|
|
419
|
+
aria-label={owner.label}
|
|
420
|
+
placeholder={missing ? t("empty") : ""}
|
|
421
|
+
value={text}
|
|
422
|
+
onBlur={() => {
|
|
423
|
+
if (!missing) onEdit(undefined);
|
|
424
|
+
}}
|
|
425
|
+
onChange={(event) => {
|
|
426
|
+
owner.onEditText(event.target.value);
|
|
427
|
+
}}
|
|
428
|
+
onKeyDown={(event) => {
|
|
429
|
+
if (event.key === "Enter" && !missing) onEdit(undefined);
|
|
430
|
+
if (event.key === "Escape") {
|
|
431
|
+
face.revert(line.path);
|
|
432
|
+
onEdit(undefined);
|
|
433
|
+
}
|
|
434
|
+
}}
|
|
435
|
+
/>
|
|
436
|
+
</CompactInput>
|
|
437
|
+
)
|
|
438
|
+
) : null}
|
|
439
|
+
{/* 编辑态只留输入框:原值不再在它旁边画一遍。 */}
|
|
440
|
+
{isEditing || line.variantsStandIn ? null : (
|
|
441
|
+
<span
|
|
442
|
+
onClick={() => {
|
|
443
|
+
if (editable) onEdit(key);
|
|
444
|
+
}}
|
|
445
|
+
>
|
|
446
|
+
{renderField(owner)}
|
|
447
|
+
</span>
|
|
448
|
+
)}
|
|
449
|
+
<VariantSelect
|
|
450
|
+
control={line.variants}
|
|
451
|
+
path={line.path}
|
|
452
|
+
face={face}
|
|
453
|
+
disabled={disabled}
|
|
454
|
+
t={t}
|
|
455
|
+
/>
|
|
456
|
+
{isEditing ? (
|
|
457
|
+
// 编辑态:确认收起这一格的编辑、取消把值退回去——都在输入框旁边,不用记快捷键。
|
|
458
|
+
<HoverActions data-role="actions" data-editing="true">
|
|
459
|
+
<button
|
|
460
|
+
type="button"
|
|
461
|
+
aria-label={t("confirmEdit")}
|
|
462
|
+
title={t("confirmEdit")}
|
|
463
|
+
onClick={() => {
|
|
464
|
+
onEdit(undefined);
|
|
465
|
+
}}
|
|
466
|
+
>
|
|
467
|
+
<IconCheckOutlineRegular size={ICON_SIZE} />
|
|
468
|
+
</button>
|
|
469
|
+
<button
|
|
470
|
+
type="button"
|
|
471
|
+
aria-label={t("cancelEdit")}
|
|
472
|
+
title={t("cancelEdit")}
|
|
473
|
+
onClick={() => {
|
|
474
|
+
face.revert(line.path);
|
|
475
|
+
onEdit(undefined);
|
|
476
|
+
// 还没值的格子:取消就是先把输入框收起来(值本身没什么可退的)。
|
|
477
|
+
onDismiss(key);
|
|
478
|
+
}}
|
|
479
|
+
>
|
|
480
|
+
<IconCloseOutlineRegular size={ICON_SIZE} />
|
|
481
|
+
</button>
|
|
482
|
+
</HoverActions>
|
|
483
|
+
) : null}
|
|
484
|
+
<HoverActions data-role="actions">
|
|
485
|
+
{line.field.staged && !disabled ? (
|
|
486
|
+
<button
|
|
487
|
+
type="button"
|
|
488
|
+
aria-label={t("revert")}
|
|
489
|
+
title={t("revert")}
|
|
490
|
+
onClick={() => {
|
|
491
|
+
face.revert(line.path);
|
|
492
|
+
}}
|
|
493
|
+
>
|
|
494
|
+
<IconRefreshOutlineRegular size={ICON_SIZE} />
|
|
495
|
+
</button>
|
|
496
|
+
) : null}
|
|
497
|
+
{line.field.overridden && !line.field.staged && !disabled ? (
|
|
498
|
+
<button
|
|
499
|
+
type="button"
|
|
500
|
+
aria-label={t("reset")}
|
|
501
|
+
title={t("reset")}
|
|
502
|
+
onClick={() => {
|
|
503
|
+
face.clear(line.path);
|
|
504
|
+
}}
|
|
505
|
+
>
|
|
506
|
+
<IconRefreshOutlineRegular size={ICON_SIZE} />
|
|
507
|
+
</button>
|
|
508
|
+
) : null}
|
|
509
|
+
<button
|
|
510
|
+
type="button"
|
|
511
|
+
aria-label={t("copyValue")}
|
|
512
|
+
title={t("copyValue")}
|
|
513
|
+
onClick={() => {
|
|
514
|
+
void navigator.clipboard
|
|
515
|
+
?.writeText(line.field.text ?? tokenFor(line.field.value))
|
|
516
|
+
.catch(() => {});
|
|
517
|
+
}}
|
|
518
|
+
>
|
|
519
|
+
<IconCopyOutlineRegular size={ICON_SIZE} />
|
|
520
|
+
</button>
|
|
521
|
+
{member === undefined ? null : (
|
|
522
|
+
<RemoveLineButton member={member} face={face} t={t} disabled={disabled} />
|
|
523
|
+
)}
|
|
524
|
+
</HoverActions>
|
|
525
|
+
</>
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/** 复制用的文本(字符串带引号,粘到哪里都是合法片段)。 */
|
|
530
|
+
function tokenFor(value: unknown): string {
|
|
531
|
+
return typeof value === "string" ? JSON.stringify(value) : JSON.stringify(value ?? null);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/** 变体切换:判别式 union 挂在标签行上,按形状选的挂在字段行上。触发元素就是当前那一支。 */
|
|
535
|
+
function VariantSelect({
|
|
536
|
+
control,
|
|
537
|
+
path,
|
|
538
|
+
face,
|
|
539
|
+
disabled,
|
|
540
|
+
t,
|
|
541
|
+
}: {
|
|
542
|
+
control: VariantControl | undefined;
|
|
543
|
+
path: readonly string[];
|
|
544
|
+
face: SchemaFormFace;
|
|
545
|
+
disabled: boolean;
|
|
546
|
+
t: SchemaFormTranslate;
|
|
547
|
+
}): ReactNode {
|
|
548
|
+
const [open, setOpen] = useState(false);
|
|
549
|
+
if (control === undefined) return null;
|
|
550
|
+
const current = control.choices[control.selected];
|
|
551
|
+
return (
|
|
552
|
+
<Menu
|
|
553
|
+
open={open}
|
|
554
|
+
dense
|
|
555
|
+
compact
|
|
556
|
+
portal
|
|
557
|
+
align="start"
|
|
558
|
+
items={control.choices.map((choice, index) => ({
|
|
559
|
+
id: String(index),
|
|
560
|
+
label: choice.label,
|
|
561
|
+
}))}
|
|
562
|
+
selectedId={String(control.selected)}
|
|
563
|
+
onSelect={(id) => {
|
|
564
|
+
setOpen(false);
|
|
565
|
+
const choice = control.choices[Number(id)];
|
|
566
|
+
if (choice !== undefined) face.set(path, choice.value);
|
|
567
|
+
}}
|
|
568
|
+
onClose={() => {
|
|
569
|
+
setOpen(false);
|
|
570
|
+
}}
|
|
571
|
+
anchor={
|
|
572
|
+
<ValueTrigger
|
|
573
|
+
type="button"
|
|
574
|
+
data-role="variant"
|
|
575
|
+
disabled={disabled}
|
|
576
|
+
aria-label={t("switchVariant")}
|
|
577
|
+
onClick={() => {
|
|
578
|
+
setOpen(true);
|
|
579
|
+
}}
|
|
580
|
+
>
|
|
581
|
+
<LineValue data-tone="empty">{current?.label ?? ""}</LineValue>
|
|
582
|
+
<IconChevronDownOutlineRegular size={ICON_SIZE} />
|
|
583
|
+
</ValueTrigger>
|
|
584
|
+
}
|
|
585
|
+
/>
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/** 开启行的前缀:字段键名(对象里的一层)或数组成员的下标;成员行另外带移除。 */
|
|
590
|
+
function LinePrefix({
|
|
591
|
+
line,
|
|
592
|
+
face,
|
|
593
|
+
t,
|
|
594
|
+
disabled,
|
|
595
|
+
}: {
|
|
596
|
+
line: Extract<EditorLine, { kind: "open" }>;
|
|
597
|
+
face: SchemaFormFace;
|
|
598
|
+
t: SchemaFormTranslate;
|
|
599
|
+
disabled: boolean;
|
|
600
|
+
}): ReactNode {
|
|
601
|
+
const member = line.member;
|
|
602
|
+
const key = member?.key ?? line.node.key;
|
|
603
|
+
// 根层的开启行没有键名,只有结构符。
|
|
604
|
+
if (key === "") return null;
|
|
605
|
+
const indexed = member !== undefined && member.index !== undefined;
|
|
606
|
+
return (
|
|
607
|
+
<>
|
|
608
|
+
<LineKey data-role="key" data-index={indexed ? "true" : undefined} title={key}>
|
|
609
|
+
{key}
|
|
610
|
+
</LineKey>
|
|
611
|
+
<LineToken>{indexed ? " " : ": "}</LineToken>
|
|
612
|
+
{/* 值本身是容器(对象/数组)的成员:它也要能移除,否则这一项删不掉。 */}
|
|
613
|
+
{member === undefined ? null : (
|
|
614
|
+
<HoverActions data-role="actions">
|
|
615
|
+
<RemoveLineButton member={member} face={face} t={t} disabled={disabled} />
|
|
616
|
+
</HoverActions>
|
|
617
|
+
)}
|
|
618
|
+
</>
|
|
619
|
+
);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/** 成员行的移除(悬停时出现):数组项按索引、字典键按键名。 */
|
|
623
|
+
function RemoveLineButton({
|
|
624
|
+
member,
|
|
625
|
+
face,
|
|
626
|
+
t,
|
|
627
|
+
disabled,
|
|
628
|
+
}: {
|
|
629
|
+
member: NonNullable<Extract<EditorLine, { kind: "field" }>["member"]>;
|
|
630
|
+
face: SchemaFormFace;
|
|
631
|
+
t: SchemaFormTranslate;
|
|
632
|
+
disabled: boolean;
|
|
633
|
+
}): ReactNode {
|
|
634
|
+
return (
|
|
635
|
+
<button
|
|
636
|
+
type="button"
|
|
637
|
+
aria-label={t("removeItem")}
|
|
638
|
+
title={t("removeItem")}
|
|
639
|
+
disabled={disabled}
|
|
640
|
+
onClick={() => {
|
|
641
|
+
if (member.index === undefined) face.removeKey(member.parent, member.key);
|
|
642
|
+
else face.removeItem(member.parent, member.index);
|
|
643
|
+
}}
|
|
644
|
+
>
|
|
645
|
+
<IconTrashOutlineRegular size={ICON_SIZE} />
|
|
646
|
+
</button>
|
|
647
|
+
);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/**
|
|
651
|
+
* 容器闭合行后面的添加入口。
|
|
652
|
+
*
|
|
653
|
+
* 数组是「追加一个空项」;对象/字典给出**候选**——对象列的是 schema 声明了、值里还没有的字段(带说明),
|
|
654
|
+
* 字典列的是业务注册的候选键(还能自己敲键名)。粘一整段 `{…}` / `[…]` 则整层覆盖。
|
|
655
|
+
*/
|
|
656
|
+
function AddLine({
|
|
657
|
+
line,
|
|
658
|
+
state,
|
|
659
|
+
face,
|
|
660
|
+
t,
|
|
661
|
+
resolveText,
|
|
662
|
+
disabled,
|
|
663
|
+
onAdded,
|
|
664
|
+
}: {
|
|
665
|
+
line: Extract<EditorLine, { kind: "close" }>;
|
|
666
|
+
state: SchemaFormState;
|
|
667
|
+
face: SchemaFormFace;
|
|
668
|
+
t: SchemaFormTranslate;
|
|
669
|
+
resolveText: ResolveText;
|
|
670
|
+
disabled: boolean;
|
|
671
|
+
onAdded: (path: readonly string[]) => void;
|
|
672
|
+
}): ReactNode {
|
|
673
|
+
const spec = line.add;
|
|
674
|
+
const [text, setText] = useState("");
|
|
675
|
+
const [open, setOpen] = useState(false);
|
|
676
|
+
const [invalid, setInvalid] = useState(false);
|
|
677
|
+
if (spec === undefined || disabled) return null;
|
|
678
|
+
const options = spec.kind === "item" ? [] : spec.options;
|
|
679
|
+
const query = text.trim().toLowerCase();
|
|
680
|
+
const matched = options.filter(
|
|
681
|
+
(option) => query === "" || option.key.toLowerCase().includes(query),
|
|
682
|
+
);
|
|
683
|
+
const label =
|
|
684
|
+
spec.kind === "item" ? t("addItemPaste") : spec.kind === "key" ? t("addKey") : t("addProperty");
|
|
685
|
+
const commit = (picked?: string): void => {
|
|
686
|
+
const raw = (picked ?? text).trim();
|
|
687
|
+
if (raw === "") return;
|
|
688
|
+
setInvalid(false);
|
|
689
|
+
if (raw.startsWith("{") || raw.startsWith("[")) {
|
|
690
|
+
try {
|
|
691
|
+
face.set(line.path, JSON.parse(raw));
|
|
692
|
+
setText("");
|
|
693
|
+
setOpen(false);
|
|
694
|
+
return;
|
|
695
|
+
} catch {
|
|
696
|
+
// 不是 JSON:当键名处理
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
if (spec.kind === "key") {
|
|
700
|
+
// 字典认任意键名。
|
|
701
|
+
face.addKey(line.path, raw);
|
|
702
|
+
onAdded([...line.path, raw]);
|
|
703
|
+
} else if (spec.kind === "item") {
|
|
704
|
+
const current = state.fields.get(fieldKey(line.path))?.value;
|
|
705
|
+
const index = Array.isArray(current) ? current.length : 0;
|
|
706
|
+
face.appendItem(line.path);
|
|
707
|
+
onAdded([...line.path, String(index)]);
|
|
708
|
+
} else {
|
|
709
|
+
// 对象只认声明过的字段:名字不在这层声明的里就说清楚,别静默丢掉。
|
|
710
|
+
if (!spec.options.some((option) => option.key === raw)) {
|
|
711
|
+
setInvalid(true);
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
face.addKey(line.path, raw);
|
|
715
|
+
onAdded([...line.path, raw]);
|
|
716
|
+
}
|
|
717
|
+
setText("");
|
|
718
|
+
setOpen(false);
|
|
719
|
+
};
|
|
720
|
+
return (
|
|
721
|
+
<AddWrap data-add={spec.kind} data-invalid={invalid ? "true" : undefined}>
|
|
722
|
+
<Menu
|
|
723
|
+
open={open && spec.kind !== "item" && matched.length > 0}
|
|
724
|
+
dense
|
|
725
|
+
compact
|
|
726
|
+
portal
|
|
727
|
+
align="start"
|
|
728
|
+
items={matched.map((option) => ({ id: option.key, label: option.key }))}
|
|
729
|
+
onSelect={(key) => {
|
|
730
|
+
commit(key);
|
|
731
|
+
}}
|
|
732
|
+
onClose={() => {
|
|
733
|
+
setOpen(false);
|
|
734
|
+
}}
|
|
735
|
+
anchor={
|
|
736
|
+
<CompactInput>
|
|
737
|
+
<Input
|
|
738
|
+
value={text}
|
|
739
|
+
placeholder={label}
|
|
740
|
+
aria-label={label}
|
|
741
|
+
disabled={disabled}
|
|
742
|
+
onFocus={() => {
|
|
743
|
+
setOpen(true);
|
|
744
|
+
}}
|
|
745
|
+
// 不监听失焦:候选菜单挂在 body 上,点它的那一瞬间输入框正好失焦——在这里收起菜单会把这
|
|
746
|
+
// 一次点击吞掉。关菜单交给菜单自己(点外面 / Esc 都会走 onClose)。
|
|
747
|
+
onChange={(event) => {
|
|
748
|
+
setText(event.target.value);
|
|
749
|
+
setInvalid(false);
|
|
750
|
+
setOpen(true);
|
|
751
|
+
}}
|
|
752
|
+
onKeyDown={(event) => {
|
|
753
|
+
if (event.key === "Enter") {
|
|
754
|
+
event.preventDefault();
|
|
755
|
+
commit(
|
|
756
|
+
matched.length === 1 && spec.kind === "prop" ? matched[0]?.key : undefined,
|
|
757
|
+
);
|
|
758
|
+
}
|
|
759
|
+
if (event.key === "Escape") {
|
|
760
|
+
setText("");
|
|
761
|
+
setOpen(false);
|
|
762
|
+
}
|
|
763
|
+
}}
|
|
764
|
+
/>
|
|
765
|
+
</CompactInput>
|
|
766
|
+
}
|
|
767
|
+
/>
|
|
768
|
+
{invalid ? <LineInvalid>{t("unknownProperty")}</LineInvalid> : null}
|
|
769
|
+
{/* 候选的说明:选中之前先看清"要加的是什么"。 */}
|
|
770
|
+
{!invalid && matched.length === 1 && matched[0]?.description !== undefined ? (
|
|
771
|
+
<LineComment title={resolveText(matched[0].description)}>
|
|
772
|
+
{resolveText(matched[0].description)}
|
|
773
|
+
</LineComment>
|
|
774
|
+
) : null}
|
|
775
|
+
</AddWrap>
|
|
776
|
+
);
|
|
777
|
+
}
|