@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,487 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 暂存式草稿模型:把用户在这一页上的编辑收成一组 path op,保存时一次提交。
|
|
3
|
+
*
|
|
4
|
+
* 每次保存都是 host 上一次带 revision 栅栏的持久写,所以控件**不直接写盘**:编辑先落在草稿上(屏幕上看到的
|
|
5
|
+
* 就是要保存的内容),`save()` 是唯一的写盘点,且先过一遍整段校验——schema 挡下的值不发请求。字段的「已覆盖」
|
|
6
|
+
* 由用户层的 presence 决定(覆盖值恰好等于默认值也是覆盖),不是值比较。
|
|
7
|
+
*
|
|
8
|
+
* 与渲染无关:控件读 `field(path)`、调 `set/setText/clear/appendItem/…`;文案与控件形状在 `fields/`。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { SettingsPathOpView } from "@deepseek-ai/dsh-api-remotes/client";
|
|
12
|
+
import { createSnapshotStore, type SnapshotStore } from "@deepseek-ai/dsh-client-store";
|
|
13
|
+
import type { ConfigForm, ConfigFormSnapshot } from "@deepseek-ai/dsh-client-ui-settings/client";
|
|
14
|
+
import type { SettingsFormShell } from "@deepseek-ai/dsh-client-ui-primitives";
|
|
15
|
+
import { emptyValueOf, fieldsOf, readPath, seekNode, type FieldNode } from "./schema-node.ts";
|
|
16
|
+
|
|
17
|
+
/** 命名空间段(一段配置)的值。 */
|
|
18
|
+
export type Section = Record<string, unknown>;
|
|
19
|
+
|
|
20
|
+
/** 文本草稿的解析结果:控件把用户输入交给保存那一刻解析。 */
|
|
21
|
+
export type TextParseResult =
|
|
22
|
+
/** 得到一个值。 */
|
|
23
|
+
| { kind: "value"; value: unknown }
|
|
24
|
+
/** 这段文本不产生写(例如 secret 留空=保留已存的值)。 */
|
|
25
|
+
| { kind: "skip" }
|
|
26
|
+
/** 不是这个字段接受的值:留在屏幕上并挡住保存。 */
|
|
27
|
+
| { kind: "invalid"; message: string };
|
|
28
|
+
|
|
29
|
+
/** 控件给文本草稿的解析规则。 */
|
|
30
|
+
export type TextParse = (text: string) => TextParseResult;
|
|
31
|
+
|
|
32
|
+
/** 整段校验失败:消息 + 它落在哪一层(页面把消息画在**出错的那一行**上)。 */
|
|
33
|
+
export interface ValidationFailure {
|
|
34
|
+
/** 面向用户的消息(已去掉 schemastery 拼的 `$…` 位置前缀)。 */
|
|
35
|
+
message: string;
|
|
36
|
+
/** 具体路径(段根起);空数组表示错误在整段上。 */
|
|
37
|
+
path: readonly string[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 把校验器抛出的错误收成一条失败。
|
|
42
|
+
*
|
|
43
|
+
* schemastery 的 `ValidationError` 把路径留在 `options.path` 上(消息里的 `$…` 前缀是给命令行看的),所以这里
|
|
44
|
+
* 取路径、去前缀——行内报错据此知道挂在哪一行。
|
|
45
|
+
* @param error - 校验器抛出的错误。
|
|
46
|
+
* @returns 消息与路径。
|
|
47
|
+
*/
|
|
48
|
+
export function failureOf(error: unknown): ValidationFailure {
|
|
49
|
+
const raw = error instanceof Error ? error.message : String(error);
|
|
50
|
+
const options = (error as { options?: { path?: readonly (string | number | symbol)[] } }).options;
|
|
51
|
+
const path = (options?.path ?? [])
|
|
52
|
+
.filter(
|
|
53
|
+
(segment): segment is string | number =>
|
|
54
|
+
typeof segment === "string" || typeof segment === "number",
|
|
55
|
+
)
|
|
56
|
+
.map(String);
|
|
57
|
+
const message = raw.replace(/^\$\S*\s/, "").trim();
|
|
58
|
+
return { message: message === "" ? raw : message, path };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** 一字段的草稿。 */
|
|
62
|
+
type DraftEdit =
|
|
63
|
+
/** 控件已经给出合法值(开关、选择器、数字步进、增删项)。 */
|
|
64
|
+
| { kind: "value"; value: unknown }
|
|
65
|
+
/** 文本输入:`parse` 在保存时跑,解析失败挡住保存。 */
|
|
66
|
+
| { kind: "text"; text: string; parse: TextParse }
|
|
67
|
+
/** 恢复默认:把字段交回组成层(用户层没有它时不算写)。 */
|
|
68
|
+
| { kind: "clear" }
|
|
69
|
+
/** 移除:无条件 unset(数组项与字典键,host 语义是删掉这一项)。 */
|
|
70
|
+
| { kind: "drop" };
|
|
71
|
+
|
|
72
|
+
/** 一字段渲染时要读的状态。 */
|
|
73
|
+
export interface DraftFieldState {
|
|
74
|
+
/** 控件要显示的值:草稿优先,否则生效层。 */
|
|
75
|
+
value: unknown;
|
|
76
|
+
/** 文本草稿(文本控件显示它而不是重新格式化值)。 */
|
|
77
|
+
text: string | undefined;
|
|
78
|
+
/** 草稿不合法时的消息(控件显示,同时挡住保存)。 */
|
|
79
|
+
invalid: string | undefined;
|
|
80
|
+
/** 保存后这一字段是否会留下用户层条目。 */
|
|
81
|
+
overridden: boolean;
|
|
82
|
+
/** 这一字段在本页有还没保存的编辑(页面上给它一处高亮与一个「撤回」)。 */
|
|
83
|
+
staged: boolean;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** 模型要读要写的那个命名空间。 */
|
|
87
|
+
export type DraftScope = ConfigForm<Section>;
|
|
88
|
+
|
|
89
|
+
/** 构造参数。 */
|
|
90
|
+
export interface SchemaDraftOptions {
|
|
91
|
+
scope: DraftScope;
|
|
92
|
+
/** 段根的字段树(`projectNode` 的结果):增删项要按它取节点默认值。 */
|
|
93
|
+
root: FieldNode;
|
|
94
|
+
/** 整段校验:返回失败(消息 + 位置),或 `undefined`。 */
|
|
95
|
+
validate: (value: Section) => ValidationFailure | undefined;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** 一条草稿编辑要写的 op(`undefined` 表示这条草稿不该产生写)。 */
|
|
99
|
+
interface PlannedWrite {
|
|
100
|
+
op: SettingsPathOpView | undefined;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* 一页配置的草稿。一个命名空间一个实例:它订阅共享配置表单,投影给渲染层读。
|
|
105
|
+
*/
|
|
106
|
+
export class SchemaDraftModel {
|
|
107
|
+
readonly #scope: DraftScope;
|
|
108
|
+
#root: FieldNode;
|
|
109
|
+
readonly #validate: (value: Section) => ValidationFailure | undefined;
|
|
110
|
+
readonly #staged = new Map<string, { path: readonly string[]; edit: DraftEdit }>();
|
|
111
|
+
readonly #listeners = new Set<() => void>();
|
|
112
|
+
readonly #unsubscribe: () => void;
|
|
113
|
+
#baseline: ConfigFormSnapshot<Section> | undefined;
|
|
114
|
+
#saving = false;
|
|
115
|
+
#failed = false;
|
|
116
|
+
#violation: ValidationFailure | undefined;
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* @param options - 本页的共享配置表单、字段树与整段校验。
|
|
120
|
+
*/
|
|
121
|
+
constructor(options: SchemaDraftOptions) {
|
|
122
|
+
this.#scope = options.scope;
|
|
123
|
+
this.#root = options.root;
|
|
124
|
+
this.#validate = options.validate;
|
|
125
|
+
this.#unsubscribe = this.#scope.subscribe(() => {
|
|
126
|
+
this.#publish();
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* 换一份字段树。
|
|
132
|
+
*
|
|
133
|
+
* 增删项要按它取节点默认值,而 schema 可能随 describe 变(行的 Config 改了)——所以控制器每次投影都同步一次,
|
|
134
|
+
* 否则加进来的项会取不到默认值。
|
|
135
|
+
* @param root - 段根的字段树。
|
|
136
|
+
*/
|
|
137
|
+
setRoot(root: FieldNode): void {
|
|
138
|
+
this.#root = root;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* 观察草稿与读数的变化。
|
|
143
|
+
* @param listener - 每次投影都调一次的回调。
|
|
144
|
+
* @returns 取消订阅。
|
|
145
|
+
*/
|
|
146
|
+
subscribe(listener: () => void): () => void {
|
|
147
|
+
this.#listeners.add(listener);
|
|
148
|
+
return () => {
|
|
149
|
+
this.#listeners.delete(listener);
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* 投影一份读数给渲染层。
|
|
155
|
+
* @param project - 用模型的当前读数造渲染要的状态。
|
|
156
|
+
* @returns 渲染层按选择器读的 store。
|
|
157
|
+
*/
|
|
158
|
+
bind<S>(project: () => S): SnapshotStore<S> {
|
|
159
|
+
const store = createSnapshotStore(project());
|
|
160
|
+
this.subscribe(() => {
|
|
161
|
+
store.set(project());
|
|
162
|
+
});
|
|
163
|
+
return store;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* 表单级状态(与上游 `SettingsForm` 的壳同形)。
|
|
168
|
+
* @returns 可用性、可写性、脏、非法、保存中、上次保存失败。
|
|
169
|
+
*/
|
|
170
|
+
shell(): SettingsFormShell {
|
|
171
|
+
const { writes, blocked } = this.#plan();
|
|
172
|
+
return {
|
|
173
|
+
available: this.#scope.getSnapshot().status === "ready",
|
|
174
|
+
writable: this.#scope.getSnapshot().writable,
|
|
175
|
+
dirty: writes.some((item) => item.op !== undefined) || blocked,
|
|
176
|
+
invalid: blocked || this.#violation !== undefined,
|
|
177
|
+
saving: this.#saving,
|
|
178
|
+
failed: this.#failed,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* 保存前的整段校验(保存被 schema 挡下时非空)。
|
|
184
|
+
* @returns 失败消息与它的位置,或 `undefined`。
|
|
185
|
+
*/
|
|
186
|
+
violation(): ValidationFailure | undefined {
|
|
187
|
+
return this.#violation;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* 读一个字段的草稿状态。
|
|
192
|
+
* @param path - 具体路径(动态段已按真实键或索引替换)。
|
|
193
|
+
* @param preview - 本轮已经算好的读数(`preview()` 的结果)。
|
|
194
|
+
*
|
|
195
|
+
* **容器的值要跟着别处的草稿走**:判别式 union 的选支就是容器的值,若这里读生效层,`type` 的切换控件会显示旧
|
|
196
|
+
* 的那一支(字段树却已经换了)。所以有 `preview` 就用它——控制器一次投影里只算一份。
|
|
197
|
+
* @returns 显示值、文本草稿、非法消息、是否覆盖。
|
|
198
|
+
*/
|
|
199
|
+
field(path: readonly string[], preview?: Section): DraftFieldState {
|
|
200
|
+
const snapshot = this.#scope.getSnapshot();
|
|
201
|
+
const staged = this.#staged.get(pathKey(path));
|
|
202
|
+
const stored = hasPath(snapshot.user, path);
|
|
203
|
+
if (staged === undefined) {
|
|
204
|
+
return {
|
|
205
|
+
value: readPath(preview ?? snapshot.value, path),
|
|
206
|
+
text: undefined,
|
|
207
|
+
invalid: undefined,
|
|
208
|
+
overridden: stored,
|
|
209
|
+
staged: false,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
switch (staged.edit.kind) {
|
|
213
|
+
case "value":
|
|
214
|
+
return {
|
|
215
|
+
value: staged.edit.value,
|
|
216
|
+
text: undefined,
|
|
217
|
+
invalid: undefined,
|
|
218
|
+
overridden: true,
|
|
219
|
+
staged: true,
|
|
220
|
+
};
|
|
221
|
+
case "text": {
|
|
222
|
+
const parsed = staged.edit.parse(staged.edit.text);
|
|
223
|
+
return {
|
|
224
|
+
value: parsed.kind === "value" ? parsed.value : readPath(snapshot.value, path),
|
|
225
|
+
text: staged.edit.text,
|
|
226
|
+
invalid: parsed.kind === "invalid" ? parsed.message : undefined,
|
|
227
|
+
overridden: true,
|
|
228
|
+
staged: true,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
case "clear":
|
|
232
|
+
return {
|
|
233
|
+
value: readPath(snapshot.base, path),
|
|
234
|
+
text: undefined,
|
|
235
|
+
invalid: undefined,
|
|
236
|
+
overridden: false,
|
|
237
|
+
staged: true,
|
|
238
|
+
};
|
|
239
|
+
case "drop":
|
|
240
|
+
return {
|
|
241
|
+
value: undefined,
|
|
242
|
+
text: undefined,
|
|
243
|
+
invalid: undefined,
|
|
244
|
+
overridden: false,
|
|
245
|
+
staged: true,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* 暂存一个合法值。
|
|
252
|
+
* @param path - 具体路径。
|
|
253
|
+
* @param value - 控件给出的值。
|
|
254
|
+
*/
|
|
255
|
+
set(path: readonly string[], value: unknown): void {
|
|
256
|
+
this.#stage(path, { kind: "value", value });
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* 暂存一段文本,保存时才解析。
|
|
261
|
+
* @param path - 具体路径。
|
|
262
|
+
* @param text - 用户输入的原文。
|
|
263
|
+
* @param parse - 解析规则;失败时给出消息并挡住保存。
|
|
264
|
+
*/
|
|
265
|
+
setText(path: readonly string[], text: string, parse: TextParse): void {
|
|
266
|
+
this.#stage(path, { kind: "text", text, parse });
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* 暂存「恢复默认」。
|
|
271
|
+
* @param path - 具体路径。
|
|
272
|
+
*/
|
|
273
|
+
clear(path: readonly string[]): void {
|
|
274
|
+
this.#stage(path, { kind: "clear" });
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* 追加一个数组项,值取该项节点的默认值。
|
|
279
|
+
* @param path - 数组字段的具体路径。
|
|
280
|
+
*/
|
|
281
|
+
appendItem(path: readonly string[]): void {
|
|
282
|
+
const node = seekNode(this.#root, path);
|
|
283
|
+
if (node?.type !== "array") return;
|
|
284
|
+
const current = readPath(this.preview(), path);
|
|
285
|
+
const index = Array.isArray(current) ? current.length : 0;
|
|
286
|
+
this.set([...path, String(index)], emptyValueOf(node.item));
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* 移除一个数组项。
|
|
291
|
+
* @param path - 数组字段的具体路径。
|
|
292
|
+
* @param index - 项在数组里的位置。
|
|
293
|
+
*/
|
|
294
|
+
removeItem(path: readonly string[], index: number): void {
|
|
295
|
+
this.#stage([...path, String(index)], { kind: "drop" });
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* 给一层加一项,值取它的默认值。
|
|
300
|
+
*
|
|
301
|
+
* 字典认任意键;对象只认 schema 声明过的字段(写一个没声明的键会被 host 挡下,所以这里直接不写)。
|
|
302
|
+
* @param path - 容器的具体路径。
|
|
303
|
+
* @param key - 要加的键。
|
|
304
|
+
*/
|
|
305
|
+
addKey(path: readonly string[], key: string): void {
|
|
306
|
+
const node = seekNode(this.#root, path);
|
|
307
|
+
if (node === undefined) return;
|
|
308
|
+
if (node.type === "dict") {
|
|
309
|
+
this.set([...path, key], emptyValueOf(node.valueNode));
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
const field = fieldsOf(node).find((candidate) => candidate.key === key);
|
|
313
|
+
if (field === undefined) return;
|
|
314
|
+
this.set([...path, key], emptyValueOf(field));
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* 删一个字典键。
|
|
319
|
+
* @param path - 字典字段的具体路径。
|
|
320
|
+
* @param key - 要删的键。
|
|
321
|
+
*/
|
|
322
|
+
removeKey(path: readonly string[], key: string): void {
|
|
323
|
+
this.#stage([...path, key], { kind: "drop" });
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* 撤销一个字段的草稿(回到它当前的生效值)——行内编辑取消时用。
|
|
328
|
+
* @param path - 具体路径。
|
|
329
|
+
*/
|
|
330
|
+
revert(path: readonly string[]): void {
|
|
331
|
+
if (!this.#staged.delete(pathKey(path))) return;
|
|
332
|
+
this.#failed = false;
|
|
333
|
+
this.#violation = undefined;
|
|
334
|
+
this.#publish();
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** 丢弃全部草稿,回到 host 的读数。 */
|
|
338
|
+
discard(): void {
|
|
339
|
+
if (this.#staged.size === 0 && !this.#failed && this.#violation === undefined) return;
|
|
340
|
+
this.#staged.clear();
|
|
341
|
+
this.#baseline = undefined;
|
|
342
|
+
this.#failed = false;
|
|
343
|
+
this.#violation = undefined;
|
|
344
|
+
this.#publish();
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* 把全部草稿写成一次带 revision 栅栏的提交。
|
|
349
|
+
*
|
|
350
|
+
* host 是「值是否被接受」的唯一权威(有些约束 schema 表达不出来),所以落盘与否读回来决定,而不是在这里
|
|
351
|
+
* 预测;没落盘的保存保留草稿,让用户改而不是重打。
|
|
352
|
+
* @returns 写完与读回之后的收束。
|
|
353
|
+
*/
|
|
354
|
+
async save(): Promise<void> {
|
|
355
|
+
const snapshot = this.#scope.getSnapshot();
|
|
356
|
+
if (this.#saving || !snapshot.writable) return;
|
|
357
|
+
const { writes, blocked } = this.#plan();
|
|
358
|
+
if (blocked) return;
|
|
359
|
+
const ops = writes.flatMap((item) => (item.op === undefined ? [] : [item.op]));
|
|
360
|
+
if (ops.length === 0) {
|
|
361
|
+
this.#violation = undefined;
|
|
362
|
+
this.#publish();
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
const failure = this.#validate(applyOps(snapshot.value ?? {}, ops));
|
|
366
|
+
if (failure !== undefined) {
|
|
367
|
+
this.#violation = failure;
|
|
368
|
+
this.#publish();
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
this.#violation = undefined;
|
|
372
|
+
this.#saving = true;
|
|
373
|
+
this.#failed = false;
|
|
374
|
+
this.#publish();
|
|
375
|
+
try {
|
|
376
|
+
const landed = await this.#scope.mutate(ops, this.#baseline?.revision);
|
|
377
|
+
if (landed) {
|
|
378
|
+
this.#staged.clear();
|
|
379
|
+
this.#baseline = undefined;
|
|
380
|
+
}
|
|
381
|
+
this.#failed = !landed;
|
|
382
|
+
} catch {
|
|
383
|
+
this.#failed = true;
|
|
384
|
+
} finally {
|
|
385
|
+
this.#saving = false;
|
|
386
|
+
this.#publish();
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** 释放对共享配置表单的订阅。 */
|
|
391
|
+
dispose(): void {
|
|
392
|
+
this.#unsubscribe();
|
|
393
|
+
this.#listeners.clear();
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** 每条草稿这一轮会写什么;被 schema 挡下的草稿没有 op,但仍然是脏的。 */
|
|
397
|
+
#plan(): { writes: PlannedWrite[]; blocked: boolean } {
|
|
398
|
+
const snapshot = this.#scope.getSnapshot();
|
|
399
|
+
const writes: PlannedWrite[] = [];
|
|
400
|
+
let blocked = false;
|
|
401
|
+
for (const { path, edit } of this.#staged.values()) {
|
|
402
|
+
if (edit.kind === "drop") {
|
|
403
|
+
writes.push({ op: { op: "unset", path: [...path] } });
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
if (edit.kind === "clear") {
|
|
407
|
+
if (hasPath(snapshot.user, path)) writes.push({ op: { op: "unset", path: [...path] } });
|
|
408
|
+
continue;
|
|
409
|
+
}
|
|
410
|
+
const parsed =
|
|
411
|
+
edit.kind === "value"
|
|
412
|
+
? { kind: "value" as const, value: edit.value }
|
|
413
|
+
: edit.parse(edit.text);
|
|
414
|
+
if (parsed.kind === "invalid") {
|
|
415
|
+
blocked = true;
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
if (parsed.kind === "skip") continue;
|
|
419
|
+
const current = readPath(snapshot.value, path);
|
|
420
|
+
if (sameValue(current, parsed.value)) continue;
|
|
421
|
+
writes.push({ op: { op: "set", path: [...path], value: parsed.value as never } });
|
|
422
|
+
}
|
|
423
|
+
return { writes, blocked };
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** 生效值套上本轮草稿后的段值:字段树按它展开(追加的数组项立刻出现),整段校验也看它。 */
|
|
427
|
+
preview(): Section {
|
|
428
|
+
const ops = this.#plan().writes.flatMap((item) => (item.op === undefined ? [] : [item.op]));
|
|
429
|
+
return applyOps(this.#scope.getSnapshot().value ?? {}, ops);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
#stage(path: readonly string[], edit: DraftEdit): void {
|
|
433
|
+
this.#baseline ??= this.#scope.getSnapshot();
|
|
434
|
+
this.#staged.set(pathKey(path), { path: [...path], edit });
|
|
435
|
+
this.#failed = false;
|
|
436
|
+
this.#violation = undefined;
|
|
437
|
+
this.#publish();
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
#publish(): void {
|
|
441
|
+
for (const listener of this.#listeners) listener();
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** 深比较:值相等就不写(避免把「改回原值」也当成一次写)。 */
|
|
446
|
+
function sameValue(left: unknown, right: unknown): boolean {
|
|
447
|
+
if (Object.is(left, right)) return true;
|
|
448
|
+
if (typeof left !== "object" || typeof right !== "object" || left === null || right === null)
|
|
449
|
+
return false;
|
|
450
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** 路径的 map 键。 */
|
|
454
|
+
function pathKey(path: readonly string[]): string {
|
|
455
|
+
return JSON.stringify(path);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** 路径末段是否在用户层里存在(覆盖判据)。 */
|
|
459
|
+
function hasPath(root: unknown, path: readonly string[]): boolean {
|
|
460
|
+
if (path.length === 0) return root !== undefined;
|
|
461
|
+
const parent = readPath(root, path.slice(0, -1));
|
|
462
|
+
const leaf = path[path.length - 1] ?? "";
|
|
463
|
+
if (Array.isArray(parent)) return Number(leaf) < parent.length;
|
|
464
|
+
if (typeof parent !== "object" || parent === null) return false;
|
|
465
|
+
return leaf in parent;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/** 把一组 op 套到一个段值上(本地读数用,不写 host)。 */
|
|
469
|
+
function applyOps(section: Section, ops: readonly SettingsPathOpView[]): Section {
|
|
470
|
+
const next = structuredClone(section);
|
|
471
|
+
for (const op of ops) {
|
|
472
|
+
const parent = readPath(next, op.path.slice(0, -1));
|
|
473
|
+
const leaf = op.path[op.path.length - 1] ?? "";
|
|
474
|
+
if (Array.isArray(parent)) {
|
|
475
|
+
if (op.op === "set") parent[Number(leaf)] = op.value;
|
|
476
|
+
else parent.splice(Number(leaf), 1);
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
479
|
+
if (typeof parent !== "object" || parent === null) continue;
|
|
480
|
+
if (op.op === "set") (parent as Section)[leaf] = op.value;
|
|
481
|
+
else Reflect.deleteProperty(parent as Section, leaf);
|
|
482
|
+
}
|
|
483
|
+
return next;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/** 一个新子节点的初值:字段树的读法在 `schema-node.ts`(变体切换也要它)。 */
|
|
487
|
+
export { emptyValueOf } from "./schema-node.ts";
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 字段默认值与文本解析:默认值就是行内值(`value.tsx`),这里只剩"什么文本算这个字段接受的值"的规则。
|
|
3
|
+
*
|
|
4
|
+
* 行式视图里值在行内点开编辑,编辑的文本交给草稿模型、保存那一刻才按这里的规则解析。
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export {
|
|
8
|
+
InlineValue,
|
|
9
|
+
isMultiline,
|
|
10
|
+
SchemaFieldDefault,
|
|
11
|
+
tokenText,
|
|
12
|
+
tokenTone,
|
|
13
|
+
valueText,
|
|
14
|
+
} from "./value.tsx";
|
|
15
|
+
import type { TextParse } from "./draft.ts";
|
|
16
|
+
import type { FieldNode } from "./schema-node.ts";
|
|
17
|
+
import type { SchemaFormTranslate } from "./slot-contract.ts";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 各类型的文本解析规则(消息来自本包字典)。
|
|
21
|
+
*
|
|
22
|
+
* 解析不只看类型:schema 上声明的界限(`pattern` / `min` / `max` / `step`)与字面量集合都在这里说话——
|
|
23
|
+
* 用户敲完当场就知道对不对,而不是等保存时被整段校验退回。
|
|
24
|
+
* @param node - 字段节点(`meta` 是约束的来源)。
|
|
25
|
+
* @param t - 本包字典。
|
|
26
|
+
* @returns 这段文本的解析规则。
|
|
27
|
+
*/
|
|
28
|
+
export function parseFor(node: FieldNode, t: SchemaFormTranslate): TextParse {
|
|
29
|
+
switch (node.type) {
|
|
30
|
+
case "number":
|
|
31
|
+
return (text) => {
|
|
32
|
+
const trimmed = text.trim();
|
|
33
|
+
const value = Number(trimmed);
|
|
34
|
+
if (trimmed === "" || !Number.isFinite(value))
|
|
35
|
+
return { kind: "invalid", message: t("invalidNumber") };
|
|
36
|
+
if (node.meta.step === 1 && !Number.isInteger(value)) {
|
|
37
|
+
return { kind: "invalid", message: t("invalidInteger") };
|
|
38
|
+
}
|
|
39
|
+
const { min, max } = node.meta;
|
|
40
|
+
if (min !== undefined && max !== undefined && (value < min || value > max)) {
|
|
41
|
+
return { kind: "invalid", message: t("invalidRange", { min, max }) };
|
|
42
|
+
}
|
|
43
|
+
if (min !== undefined && value < min) {
|
|
44
|
+
return { kind: "invalid", message: t("invalidMin", { min }) };
|
|
45
|
+
}
|
|
46
|
+
if (max !== undefined && value > max) {
|
|
47
|
+
return { kind: "invalid", message: t("invalidMax", { max }) };
|
|
48
|
+
}
|
|
49
|
+
return { kind: "value", value };
|
|
50
|
+
};
|
|
51
|
+
case "string": {
|
|
52
|
+
// 声明了 `pattern()` 就按它判:不匹配的文本不进草稿。
|
|
53
|
+
const pattern = node.meta.pattern;
|
|
54
|
+
if (pattern === undefined) return (text) => ({ kind: "value", value: text });
|
|
55
|
+
return (text) =>
|
|
56
|
+
new RegExp(pattern).test(text)
|
|
57
|
+
? { kind: "value", value: text }
|
|
58
|
+
: { kind: "invalid", message: t("invalidPattern", { pattern }) };
|
|
59
|
+
}
|
|
60
|
+
case "boolean":
|
|
61
|
+
return (text) => {
|
|
62
|
+
const trimmed = text.trim();
|
|
63
|
+
if (trimmed === "true") return { kind: "value", value: true };
|
|
64
|
+
if (trimmed === "false") return { kind: "value", value: false };
|
|
65
|
+
return { kind: "invalid", message: t("invalidBoolean") };
|
|
66
|
+
};
|
|
67
|
+
case "const":
|
|
68
|
+
return (text) =>
|
|
69
|
+
text === String(node.type === "const" ? node.value : "") ||
|
|
70
|
+
text.trim() === JSON.stringify(node.value)
|
|
71
|
+
? { kind: "value", value: node.value }
|
|
72
|
+
: { kind: "invalid", message: t("invalidConst") };
|
|
73
|
+
case "bitset":
|
|
74
|
+
return (text) => {
|
|
75
|
+
const value = Number(text.trim());
|
|
76
|
+
return Number.isFinite(value)
|
|
77
|
+
? { kind: "value", value }
|
|
78
|
+
: { kind: "invalid", message: t("invalidNumber") };
|
|
79
|
+
};
|
|
80
|
+
case "any":
|
|
81
|
+
return (text) => {
|
|
82
|
+
try {
|
|
83
|
+
return { kind: "value", value: JSON.parse(text) };
|
|
84
|
+
} catch {
|
|
85
|
+
return { kind: "invalid", message: t("invalidJson") };
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
default:
|
|
89
|
+
// 字面量集合的 union(枚举那种):只收集合里的成员。
|
|
90
|
+
if (node.type === "union" && node.choices !== undefined) {
|
|
91
|
+
const choices = node.choices;
|
|
92
|
+
const options = choices.map((choice) => String(choice)).join(" / ");
|
|
93
|
+
return (text) =>
|
|
94
|
+
choices.some((choice) => String(choice) === text)
|
|
95
|
+
? { kind: "value", value: text }
|
|
96
|
+
: { kind: "invalid", message: t("invalidChoice", { choices: options }) };
|
|
97
|
+
}
|
|
98
|
+
return (text) => ({ kind: "value", value: text });
|
|
99
|
+
}
|
|
100
|
+
}
|