@axi-engine/fields 0.1.0
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 +24 -0
- package/dist/index.d.mts +356 -0
- package/dist/index.d.ts +356 -0
- package/dist/index.js +653 -0
- package/dist/index.mjs +614 -0
- package/package.json +35 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,614 @@
|
|
|
1
|
+
// src/fields-types.ts
|
|
2
|
+
var FieldsNodeType = /* @__PURE__ */ ((FieldsNodeType2) => {
|
|
3
|
+
FieldsNodeType2["fieldTree"] = "FieldTree";
|
|
4
|
+
FieldsNodeType2["fields"] = "Fields";
|
|
5
|
+
return FieldsNodeType2;
|
|
6
|
+
})(FieldsNodeType || {});
|
|
7
|
+
|
|
8
|
+
// src/field-policies.ts
|
|
9
|
+
var _ClampPolicy = class _ClampPolicy {
|
|
10
|
+
constructor(min, max) {
|
|
11
|
+
this.min = min;
|
|
12
|
+
this.max = max;
|
|
13
|
+
this.id = _ClampPolicy.id;
|
|
14
|
+
}
|
|
15
|
+
apply(val) {
|
|
16
|
+
return Math.max(this.min, Math.min(this.max, val));
|
|
17
|
+
}
|
|
18
|
+
updateBounds(min, max) {
|
|
19
|
+
this.min = min;
|
|
20
|
+
this.max = max;
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
_ClampPolicy.id = "clamp";
|
|
24
|
+
var ClampPolicy = _ClampPolicy;
|
|
25
|
+
var _ClampMinPolicy = class _ClampMinPolicy {
|
|
26
|
+
constructor(min) {
|
|
27
|
+
this.min = min;
|
|
28
|
+
this.id = _ClampMinPolicy.id;
|
|
29
|
+
}
|
|
30
|
+
apply(val) {
|
|
31
|
+
return Math.max(this.min, val);
|
|
32
|
+
}
|
|
33
|
+
updateBounds(min) {
|
|
34
|
+
this.min = min;
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
_ClampMinPolicy.id = "clampMin";
|
|
38
|
+
var ClampMinPolicy = _ClampMinPolicy;
|
|
39
|
+
var _ClampMaxPolicy = class _ClampMaxPolicy {
|
|
40
|
+
constructor(max) {
|
|
41
|
+
this.max = max;
|
|
42
|
+
this.id = _ClampMaxPolicy.id;
|
|
43
|
+
}
|
|
44
|
+
apply(val) {
|
|
45
|
+
return Math.min(this.max, val);
|
|
46
|
+
}
|
|
47
|
+
updateBounds(max) {
|
|
48
|
+
this.max = max;
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
_ClampMaxPolicy.id = "clampMax";
|
|
52
|
+
var ClampMaxPolicy = _ClampMaxPolicy;
|
|
53
|
+
function clampPolicy(min, max) {
|
|
54
|
+
return new ClampPolicy(min, max);
|
|
55
|
+
}
|
|
56
|
+
function clampMinPolicy(min) {
|
|
57
|
+
return new ClampMinPolicy(min);
|
|
58
|
+
}
|
|
59
|
+
function clampMaxPolicy(max) {
|
|
60
|
+
return new ClampMaxPolicy(max);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// src/field.ts
|
|
64
|
+
import { signal } from "@preact/signals-core";
|
|
65
|
+
var Field = class {
|
|
66
|
+
/**
|
|
67
|
+
* Creates an instance of a Field.
|
|
68
|
+
* @param name A unique identifier for the field.
|
|
69
|
+
* @param initialVal The initial value of the field.
|
|
70
|
+
* @param options Optional configuration for the field.
|
|
71
|
+
* @param options.policies An array of policies to apply to the field's value on every `set` operation.
|
|
72
|
+
*/
|
|
73
|
+
constructor(name, initialVal, options) {
|
|
74
|
+
this.policies = /* @__PURE__ */ new Map();
|
|
75
|
+
this._val = signal(initialVal);
|
|
76
|
+
this.name = name;
|
|
77
|
+
options?.policies?.forEach((policy) => this.policies.set(policy.id, policy));
|
|
78
|
+
this.set(initialVal);
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Gets the current raw value of the field.
|
|
82
|
+
* For reactive updates, it's recommended to use the `.signal` property instead.
|
|
83
|
+
*/
|
|
84
|
+
get val() {
|
|
85
|
+
return this._val.value;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Provides readonly access to the underlying Preact Signal.
|
|
89
|
+
* Subscribe to this signal to react to value changes.
|
|
90
|
+
*/
|
|
91
|
+
get signal() {
|
|
92
|
+
return this._val;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Sets a new value for the field.
|
|
96
|
+
* The provided value will be processed by all registered policies before the underlying signal is updated.
|
|
97
|
+
* @param val The new value to set.
|
|
98
|
+
*/
|
|
99
|
+
set(val) {
|
|
100
|
+
let finalVal = val;
|
|
101
|
+
this.policies.forEach((policy) => finalVal = policy.apply(finalVal));
|
|
102
|
+
this._val.value = finalVal;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Retrieves a specific policy instance by its ID.
|
|
106
|
+
* Useful for accessing a policy's internal state or methods.
|
|
107
|
+
* @template P The expected type of the policy.
|
|
108
|
+
* @param id The unique ID of the policy to retrieve.
|
|
109
|
+
* @returns The policy instance, or `undefined` if not found.
|
|
110
|
+
*/
|
|
111
|
+
getPolicy(id) {
|
|
112
|
+
return this.policies.get(id);
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Adds a new policy to the field or replaces an existing one with the same ID.
|
|
116
|
+
* The new policy will be applied on the next `set()` operation.
|
|
117
|
+
* If a policy with the same ID already exists, its `destroy` method will be called before it is replaced.
|
|
118
|
+
* @param policy The policy instance to add.
|
|
119
|
+
*/
|
|
120
|
+
addPolicy(policy) {
|
|
121
|
+
const existed = this.policies.get(policy.id);
|
|
122
|
+
existed?.destroy?.();
|
|
123
|
+
this.policies.set(policy.id, policy);
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Removes a policy from the field by its ID and call `destroy` method.
|
|
127
|
+
* @param policyId The unique ID of the policy to remove.
|
|
128
|
+
* @returns `true` if the policy was found and removed, otherwise `false`.
|
|
129
|
+
*/
|
|
130
|
+
removePolicy(policyId) {
|
|
131
|
+
const policyToRemove = this.policies.get(policyId);
|
|
132
|
+
if (!policyToRemove) {
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
policyToRemove.destroy?.();
|
|
136
|
+
return this.policies.delete(policyId);
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Removes all policies from the field.
|
|
140
|
+
* After this, `set()` will no longer apply any transformations to the value until new policies are added.
|
|
141
|
+
*/
|
|
142
|
+
clearPolicies() {
|
|
143
|
+
this.policies.forEach((policy) => policy.destroy?.());
|
|
144
|
+
this.policies.clear();
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Forces the current value to be re-processed by all policies.
|
|
148
|
+
* Useful if a policy's logic has changed and you need to re-evaluate the current state.
|
|
149
|
+
*/
|
|
150
|
+
reapplyPolicies() {
|
|
151
|
+
this.set(this.val);
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Cleans up resources used by the field and its policies.
|
|
155
|
+
* This should be called when the field is no longer needed to prevent memory leaks from reactive policies.
|
|
156
|
+
*/
|
|
157
|
+
destroy() {
|
|
158
|
+
this.clearPolicies();
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
// src/number-field.ts
|
|
163
|
+
import { isNullOrUndefined } from "@axi-engine/utils";
|
|
164
|
+
var NumberField = class extends Field {
|
|
165
|
+
get min() {
|
|
166
|
+
const policy = this.getPolicy(ClampPolicy.id) ?? this.getPolicy(ClampMinPolicy.id);
|
|
167
|
+
return policy?.min;
|
|
168
|
+
}
|
|
169
|
+
get max() {
|
|
170
|
+
const policy = this.getPolicy(ClampPolicy.id) ?? this.getPolicy(ClampMaxPolicy.id);
|
|
171
|
+
return policy?.max;
|
|
172
|
+
}
|
|
173
|
+
get isMin() {
|
|
174
|
+
const min = this.min;
|
|
175
|
+
return isNullOrUndefined(min) ? false : this.val <= min;
|
|
176
|
+
}
|
|
177
|
+
get isMax() {
|
|
178
|
+
const max = this.max;
|
|
179
|
+
return isNullOrUndefined(max) ? false : this.val >= max;
|
|
180
|
+
}
|
|
181
|
+
constructor(name, initialVal, options) {
|
|
182
|
+
const policies = options?.policies ?? [];
|
|
183
|
+
if (!isNullOrUndefined(options?.min) && !isNullOrUndefined(options?.max)) {
|
|
184
|
+
policies.unshift(clampPolicy(options.min, options.max));
|
|
185
|
+
} else if (!isNullOrUndefined(options?.min)) {
|
|
186
|
+
policies.unshift(clampMinPolicy(options.min));
|
|
187
|
+
} else if (!isNullOrUndefined(options?.max)) {
|
|
188
|
+
policies.unshift(clampMaxPolicy(options.max));
|
|
189
|
+
}
|
|
190
|
+
super(name, initialVal, { policies });
|
|
191
|
+
}
|
|
192
|
+
inc(amount = 1) {
|
|
193
|
+
this.set(this.val + amount);
|
|
194
|
+
}
|
|
195
|
+
dec(amount = 1) {
|
|
196
|
+
this.set(this.val - amount);
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
// src/base-fields.ts
|
|
201
|
+
import { signal as signal2 } from "@preact/signals-core";
|
|
202
|
+
import { AxiEventEmitter } from "@axi-engine/events";
|
|
203
|
+
import { throwIf } from "@axi-engine/utils";
|
|
204
|
+
var BaseFields = class {
|
|
205
|
+
constructor() {
|
|
206
|
+
this._fields = signal2(/* @__PURE__ */ new Map());
|
|
207
|
+
this.events = new AxiEventEmitter();
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* A readonly signal providing access to the current map of fields.
|
|
211
|
+
* Use this signal with `effect` to react when fields are added or removed from the collection.
|
|
212
|
+
* Avoid to change any data in the map manually.
|
|
213
|
+
*/
|
|
214
|
+
get fields() {
|
|
215
|
+
return this._fields;
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Checks if a field with the given name exists in the collection.
|
|
219
|
+
* @param name The name of the field to check.
|
|
220
|
+
* @returns `true` if the field exists, otherwise `false`.
|
|
221
|
+
*/
|
|
222
|
+
has(name) {
|
|
223
|
+
return this._fields.value.has(name);
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Creates and adds a new `Field` to the collection.
|
|
227
|
+
* @param name The unique name for the new field.
|
|
228
|
+
* @param initialValue The initial value for the new field.
|
|
229
|
+
* @returns The newly created `Field` instance.
|
|
230
|
+
*/
|
|
231
|
+
create(name, initialValue) {
|
|
232
|
+
return this.add(new Field(name, initialValue));
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Adds a pre-existing `Field` instance to the collection.
|
|
236
|
+
* Throws an error if a field with the same name already exists.
|
|
237
|
+
* @param field The `Field` instance to add.
|
|
238
|
+
* @returns The added `Field` instance.
|
|
239
|
+
*/
|
|
240
|
+
add(field) {
|
|
241
|
+
throwIf(this.has(field.name), `Field with name '${field.name}' already exists`);
|
|
242
|
+
const fieldsMap = new Map(this._fields.value);
|
|
243
|
+
fieldsMap.set(field.name, field);
|
|
244
|
+
this._fields.value = fieldsMap;
|
|
245
|
+
this.events.emit("created", {
|
|
246
|
+
fieldName: field.name,
|
|
247
|
+
field
|
|
248
|
+
});
|
|
249
|
+
return field;
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Retrieves a field by its name.
|
|
253
|
+
* Throws an error if the field does not exist.
|
|
254
|
+
* @param name The name of the field to retrieve.
|
|
255
|
+
* @returns The `Field` instance.
|
|
256
|
+
*/
|
|
257
|
+
get(name) {
|
|
258
|
+
throwIf(!this._fields.value.has(name), `Field with name '${name}' not exists`);
|
|
259
|
+
return this._fields.value.get(name);
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* "Update or Insert": Updates a field's value if it exists, or creates a new one if it doesn't.
|
|
263
|
+
* @param name The name of the field.
|
|
264
|
+
* @param value The value to set.
|
|
265
|
+
* @returns The existing or newly created `Field` instance.
|
|
266
|
+
*/
|
|
267
|
+
upset(name, value) {
|
|
268
|
+
if (this.has(name)) {
|
|
269
|
+
const field = this.get(name);
|
|
270
|
+
field.set(value);
|
|
271
|
+
return field;
|
|
272
|
+
}
|
|
273
|
+
return this.create(name, value);
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Removes one or more fields from the collection.
|
|
277
|
+
* This method ensures that the `destroy` method of each removed field is called to clean up its resources.
|
|
278
|
+
* @param names A single name or an array of names to remove.
|
|
279
|
+
*/
|
|
280
|
+
remove(names) {
|
|
281
|
+
const namesToRemove = Array.isArray(names) ? names : [names];
|
|
282
|
+
const fieldsMap = new Map(this._fields.value);
|
|
283
|
+
const reallyRemoved = namesToRemove.filter((name) => {
|
|
284
|
+
const field = fieldsMap.get(name);
|
|
285
|
+
if (!field) {
|
|
286
|
+
return false;
|
|
287
|
+
}
|
|
288
|
+
field.destroy();
|
|
289
|
+
fieldsMap.delete(name);
|
|
290
|
+
return true;
|
|
291
|
+
});
|
|
292
|
+
if (!reallyRemoved.length) {
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
this._fields.value = fieldsMap;
|
|
296
|
+
this.events.emit("removed", { fieldNames: reallyRemoved });
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Removes all fields from the collection, ensuring each is properly destroyed.
|
|
300
|
+
*/
|
|
301
|
+
clear() {
|
|
302
|
+
this.remove(Array.from(this._fields.value.keys()));
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Creates a serializable snapshot of the current state of all fields.
|
|
306
|
+
* @returns A plain JavaScript object representing the values of all fields.
|
|
307
|
+
*/
|
|
308
|
+
snapshot() {
|
|
309
|
+
const dump = {
|
|
310
|
+
__type: "Fields" /* fields */
|
|
311
|
+
};
|
|
312
|
+
this._fields.value.forEach((field, key) => dump[key] = field.val);
|
|
313
|
+
return dump;
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Restores the state of the fields from a snapshot.
|
|
317
|
+
* It uses the `upset` logic to create or update fields based on the snapshot data.
|
|
318
|
+
* @param snapshot The snapshot object to load.
|
|
319
|
+
*/
|
|
320
|
+
hydrate(snapshot) {
|
|
321
|
+
for (let key in snapshot) {
|
|
322
|
+
if (key === "__type") {
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
this.upset(key, snapshot[key]);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
// src/fields.ts
|
|
331
|
+
import { throwIf as throwIf2 } from "@axi-engine/utils";
|
|
332
|
+
var Fields = class extends BaseFields {
|
|
333
|
+
createNumber(name, initialValue, options) {
|
|
334
|
+
return this.add(new NumberField(name, initialValue, options));
|
|
335
|
+
}
|
|
336
|
+
upsetNumber(name, value, options) {
|
|
337
|
+
if (this.has(name)) {
|
|
338
|
+
const field = this.getNumber(name);
|
|
339
|
+
field.set(value);
|
|
340
|
+
return field;
|
|
341
|
+
}
|
|
342
|
+
return this.createNumber(name, value, options);
|
|
343
|
+
}
|
|
344
|
+
getNumber(name) {
|
|
345
|
+
const field = this.get(name);
|
|
346
|
+
throwIf2(!(field instanceof NumberField), `wrong field type, field ${name} not a instance of NUmberField`);
|
|
347
|
+
return field;
|
|
348
|
+
}
|
|
349
|
+
create(name, initialValue) {
|
|
350
|
+
return this.add(new Field(name, initialValue));
|
|
351
|
+
}
|
|
352
|
+
upset(name, value) {
|
|
353
|
+
if (this.has(name)) {
|
|
354
|
+
const field = this.get(name);
|
|
355
|
+
field.set(value);
|
|
356
|
+
return field;
|
|
357
|
+
}
|
|
358
|
+
return this.create(name, value);
|
|
359
|
+
}
|
|
360
|
+
get(name) {
|
|
361
|
+
throwIf2(!this._fields.value.has(name), `Field with name '${name}' not exists`);
|
|
362
|
+
return this._fields.value.get(name);
|
|
363
|
+
}
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
// src/typed-fields.ts
|
|
367
|
+
var TypedFields = class extends BaseFields {
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
// src/field-tree.ts
|
|
371
|
+
import { signal as signal3 } from "@preact/signals-core";
|
|
372
|
+
import { ensurePathArray, ensurePathString, throwIf as throwIf3, throwIfEmpty } from "@axi-engine/utils";
|
|
373
|
+
import { AxiEventEmitter as AxiEventEmitter2 } from "@axi-engine/events";
|
|
374
|
+
var FieldTree = class _FieldTree {
|
|
375
|
+
constructor() {
|
|
376
|
+
this.events = new AxiEventEmitter2();
|
|
377
|
+
this._items = signal3(/* @__PURE__ */ new Map());
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* A readonly signal providing access to the map of child nodes.
|
|
381
|
+
* Use this with `effect` to react to structural changes in the tree (e.g., adding a new `Fields` container).
|
|
382
|
+
*/
|
|
383
|
+
get items() {
|
|
384
|
+
return this._items;
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* Checks if a path to a node or fields container or field exists without creating it.
|
|
388
|
+
* @returns true if the entire path exists, false otherwise.
|
|
389
|
+
*/
|
|
390
|
+
hasPath(path) {
|
|
391
|
+
const pathParts = ensurePathArray(path);
|
|
392
|
+
let currentNode = this;
|
|
393
|
+
for (let i = 0; i < pathParts.length; i++) {
|
|
394
|
+
const part = pathParts[i];
|
|
395
|
+
const nextNode = currentNode._items.value.get(part);
|
|
396
|
+
if (!nextNode) {
|
|
397
|
+
return false;
|
|
398
|
+
}
|
|
399
|
+
if (nextNode instanceof BaseFields) {
|
|
400
|
+
if (i === pathParts.length - 1) {
|
|
401
|
+
return true;
|
|
402
|
+
}
|
|
403
|
+
throwIf3(
|
|
404
|
+
pathParts.length - i > 2,
|
|
405
|
+
`Path validation failed, full path: ${ensurePathString(path)}, has extra nodes after Fields placed at: ${ensurePathString(pathParts.slice(0, i + 1))}`
|
|
406
|
+
);
|
|
407
|
+
return nextNode.has(pathParts[i + 1]);
|
|
408
|
+
}
|
|
409
|
+
currentNode = nextNode;
|
|
410
|
+
}
|
|
411
|
+
return true;
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* Retrieves a child node and asserts that it is an instance of `FieldTree`.
|
|
415
|
+
* @param name The name of the child node.
|
|
416
|
+
* @returns The `FieldTree` instance.
|
|
417
|
+
* @throws If the node does not exist or is not a `FieldTree`.
|
|
418
|
+
*/
|
|
419
|
+
getFieldTree(name) {
|
|
420
|
+
const node = this.getNode(name);
|
|
421
|
+
throwIf3(!(node instanceof _FieldTree), `Node '${name}' should be instance of FieldTree`);
|
|
422
|
+
return node;
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* Retrieves a child node and asserts that it is an instance of `Fields`.
|
|
426
|
+
* @param name The name of the child node.
|
|
427
|
+
* @returns The `Fields` instance.
|
|
428
|
+
* @throws If the node does not exist or is not a `Fields` container.
|
|
429
|
+
*/
|
|
430
|
+
getFields(name) {
|
|
431
|
+
const node = this.getNode(name);
|
|
432
|
+
throwIf3(!(node instanceof Fields), `Node '${name}' should be instance of Fields`);
|
|
433
|
+
return node;
|
|
434
|
+
}
|
|
435
|
+
/**
|
|
436
|
+
* Retrieves a child node and asserts that it is an instance of `TypedFields`.
|
|
437
|
+
* @param name The name of the child node.
|
|
438
|
+
* @returns The `TypedFields` instance.
|
|
439
|
+
* @throws If the node does not exist or is not a `TypedFields` container.
|
|
440
|
+
*/
|
|
441
|
+
getTypedFields(name) {
|
|
442
|
+
const node = this.getNode(name);
|
|
443
|
+
throwIf3(!(node instanceof TypedFields), `Node '${name}' should be instance of TypedFields`);
|
|
444
|
+
return node;
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* Retrieves a child node from this tree level without type checking.
|
|
448
|
+
* @param name The name of the child node.
|
|
449
|
+
* @returns The retrieved node, which can be a `FieldTree` or a `Fields` container.
|
|
450
|
+
* @throws If a node with the given name cannot be found.
|
|
451
|
+
*/
|
|
452
|
+
getNode(name) {
|
|
453
|
+
const node = this._items.value.get(name);
|
|
454
|
+
throwIfEmpty(node, `Can't find node with name '${name}'`);
|
|
455
|
+
return node;
|
|
456
|
+
}
|
|
457
|
+
/**
|
|
458
|
+
* Creates and adds a new `FieldTree` node as a child of this one.
|
|
459
|
+
* @param name The unique name for the new `FieldTree` node.
|
|
460
|
+
* @returns The newly created `FieldTree` instance.
|
|
461
|
+
*/
|
|
462
|
+
createFieldTree(name) {
|
|
463
|
+
return this.createNode(name, _FieldTree);
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* Creates and adds a new `Fields` container as a child of this one.
|
|
467
|
+
* @param name The unique name for the new `Fields` container.
|
|
468
|
+
* @returns The newly created `Fields` instance.
|
|
469
|
+
*/
|
|
470
|
+
createFields(name) {
|
|
471
|
+
return this.createNode(name, Fields);
|
|
472
|
+
}
|
|
473
|
+
/**
|
|
474
|
+
* Creates and adds a new `TypedFields` container as a child of this one.
|
|
475
|
+
* @param name The unique name for the new `TypedFields` container.
|
|
476
|
+
* @returns The newly created `TypedFields` instance.
|
|
477
|
+
*/
|
|
478
|
+
createTypedFields(name) {
|
|
479
|
+
return this.createNode(name, TypedFields);
|
|
480
|
+
}
|
|
481
|
+
/**
|
|
482
|
+
* Navigates through the tree using a path and returns the `Fields` container at the end.
|
|
483
|
+
* @param path The path to the `Fields` container (e.g., 'player/stats').
|
|
484
|
+
* @returns The `Fields` container at the specified path.
|
|
485
|
+
* @throws If the path is empty, or any intermediate node is not a `FieldTree`.
|
|
486
|
+
*/
|
|
487
|
+
getFieldsByPath(path) {
|
|
488
|
+
const pathParts = ensurePathArray(path);
|
|
489
|
+
throwIf3(!pathParts.length, "Empty path");
|
|
490
|
+
let container = this;
|
|
491
|
+
for (let i = 0; i < pathParts.length - 1; i++) {
|
|
492
|
+
container = container.getFieldTree(pathParts[i]);
|
|
493
|
+
}
|
|
494
|
+
return container.getFields(pathParts[pathParts.length - 1]);
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* Creates a `Field` at a deeply nested path.
|
|
498
|
+
* The last part of the path is treated as the field name, and the preceding parts as the path to its container.
|
|
499
|
+
* @param path The full path to the new field (e.g., 'player/stats/health').
|
|
500
|
+
* @param initialValue The initial value for the new field.
|
|
501
|
+
* @returns The newly created `Field` instance.
|
|
502
|
+
*/
|
|
503
|
+
create(path, initialValue) {
|
|
504
|
+
const fullPath = [...ensurePathArray(path)];
|
|
505
|
+
const fieldName = fullPath.pop();
|
|
506
|
+
throwIf3(!fullPath.length, `Wrong path format of one field creating: '${ensurePathString(path)}', should be at least two sections`);
|
|
507
|
+
return this.getFieldsByPath(fullPath).create(fieldName, initialValue);
|
|
508
|
+
}
|
|
509
|
+
/**
|
|
510
|
+
* Creates a `NumberField` at a deeply nested path.
|
|
511
|
+
* @param path The full path to the new field (e.g., 'player/stats/mana').
|
|
512
|
+
* @param initialValue The initial numeric value.
|
|
513
|
+
* @returns The newly created `NumberField` instance.
|
|
514
|
+
*/
|
|
515
|
+
createNumber(path, initialValue) {
|
|
516
|
+
const fullPath = [...ensurePathArray(path)];
|
|
517
|
+
const fieldName = fullPath.pop();
|
|
518
|
+
return this.getFieldsByPath(fullPath).createNumber(fieldName, initialValue);
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* Retrieves a `Field` from a deeply nested path.
|
|
522
|
+
* @param path The full path to the field (e.g., 'player/stats/name').
|
|
523
|
+
* @returns The `Field` instance at the specified path.
|
|
524
|
+
*/
|
|
525
|
+
get(path) {
|
|
526
|
+
const fullPath = [...ensurePathArray(path)];
|
|
527
|
+
const fieldName = fullPath.pop();
|
|
528
|
+
return this.getFieldsByPath(fullPath).get(fieldName);
|
|
529
|
+
}
|
|
530
|
+
/**
|
|
531
|
+
* Retrieves a `NumberField` from a deeply nested path.
|
|
532
|
+
* @param path The full path to the number field (e.g., 'player/stats/level').
|
|
533
|
+
* @returns The `NumberField` instance at the specified path.
|
|
534
|
+
*/
|
|
535
|
+
getNumber(path) {
|
|
536
|
+
const fullPath = [...ensurePathArray(path)];
|
|
537
|
+
const fieldName = fullPath.pop();
|
|
538
|
+
return this.getFieldsByPath(fullPath).getNumber(fieldName);
|
|
539
|
+
}
|
|
540
|
+
/**
|
|
541
|
+
* Creates a serializable snapshot of the entire tree and its contained fields.
|
|
542
|
+
* @returns A plain JavaScript object representing the complete state managed by this tree.
|
|
543
|
+
*/
|
|
544
|
+
snapshot() {
|
|
545
|
+
const dump = {
|
|
546
|
+
__type: "FieldTree" /* fieldTree */
|
|
547
|
+
};
|
|
548
|
+
this._items.value.forEach((node, key) => dump[key] = node.snapshot());
|
|
549
|
+
return dump;
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* Restores the state of the tree from a snapshot.
|
|
553
|
+
* It intelligently creates missing nodes based on `__type` metadata and delegates hydration to child nodes.
|
|
554
|
+
* @param snapshot The snapshot object to load.
|
|
555
|
+
*/
|
|
556
|
+
hydrate(snapshot) {
|
|
557
|
+
for (const key in snapshot) {
|
|
558
|
+
if (key === "__type") {
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
561
|
+
const field = snapshot[key];
|
|
562
|
+
const type = field?.__type;
|
|
563
|
+
let node = this._items.value.get(key);
|
|
564
|
+
if (!node) {
|
|
565
|
+
if (type === "Fields" /* fields */) {
|
|
566
|
+
node = this.createFields(key);
|
|
567
|
+
} else if (type === "FieldTree" /* fieldTree */) {
|
|
568
|
+
node = this.createFieldTree(key);
|
|
569
|
+
} else {
|
|
570
|
+
console.warn(`Node '${key}' in snapshot has no __type metadata. Skipping.`);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
node?.hydrate(field);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
/**
|
|
577
|
+
* @private
|
|
578
|
+
* Generic internal method for creating and adding a new node to the tree.
|
|
579
|
+
* @param name The name of the node to create.
|
|
580
|
+
* @param ctor The constructor for the node type (e.g., `FieldTree` or `Fields`).
|
|
581
|
+
* @returns The newly created node instance.
|
|
582
|
+
*/
|
|
583
|
+
createNode(name, ctor) {
|
|
584
|
+
const currentItems = this._items.value;
|
|
585
|
+
throwIf3(currentItems.has(name), `Can't create node with name: '${name}', node already exists`);
|
|
586
|
+
const res = new ctor();
|
|
587
|
+
const newItems = new Map(currentItems);
|
|
588
|
+
newItems.set(name, res);
|
|
589
|
+
this._items.value = newItems;
|
|
590
|
+
this.events.emit("created", {
|
|
591
|
+
type: "created",
|
|
592
|
+
name,
|
|
593
|
+
path: [],
|
|
594
|
+
// todo: need to decide how to pass full path
|
|
595
|
+
node: res
|
|
596
|
+
});
|
|
597
|
+
return res;
|
|
598
|
+
}
|
|
599
|
+
};
|
|
600
|
+
export {
|
|
601
|
+
BaseFields,
|
|
602
|
+
ClampMaxPolicy,
|
|
603
|
+
ClampMinPolicy,
|
|
604
|
+
ClampPolicy,
|
|
605
|
+
Field,
|
|
606
|
+
FieldTree,
|
|
607
|
+
Fields,
|
|
608
|
+
FieldsNodeType,
|
|
609
|
+
NumberField,
|
|
610
|
+
TypedFields,
|
|
611
|
+
clampMaxPolicy,
|
|
612
|
+
clampMinPolicy,
|
|
613
|
+
clampPolicy
|
|
614
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@axi-engine/fields",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"axi-engine",
|
|
8
|
+
"typescript",
|
|
9
|
+
"gamedev",
|
|
10
|
+
"fields"
|
|
11
|
+
],
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"main": "./dist/index.js",
|
|
14
|
+
"module": "./dist/index.mjs",
|
|
15
|
+
"exports": {
|
|
16
|
+
".": {
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"import": "./dist/index.mjs",
|
|
19
|
+
"require": "./dist/index.js"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build": "tsup src/index.ts --format cjs,esm --dts --clean",
|
|
24
|
+
"docs": "typedoc src/index.ts --out docs/api --options ../../typedoc.json",
|
|
25
|
+
"test": "echo 'No tests yet for @axi-engine/fields'"
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"dist"
|
|
29
|
+
],
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"@preact/signals-core": "^1.12.1",
|
|
32
|
+
"@axi-engine/events": "*",
|
|
33
|
+
"@axi-engine/utils": "*"
|
|
34
|
+
}
|
|
35
|
+
}
|