@rohal12/spindle 0.59.14 → 0.59.15
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/dist/pkg/format.js +1 -1
- package/dist/pkg/headless.js +537 -238
- package/dist/pkg/story-variables.js +313 -149
- package/package.json +1 -1
- package/src/class-registry.ts +105 -35
- package/src/components/Passage.tsx +21 -13
- package/src/components/PassageDialog.tsx +12 -2
- package/src/components/macros/Radiobutton.tsx +4 -1
- package/src/execute-mutation.ts +3 -2
- package/src/markup/render.tsx +17 -1
- package/src/markup/spindle.peggy +16 -0
- package/src/store.ts +12 -2
- package/src/structural.ts +74 -38
- package/src/triggers.ts +6 -2
- package/src/utils/stable-key.ts +8 -4
- package/src/utils/value-kinds.ts +42 -0
- package/types/index.d.ts +3 -1
package/src/structural.ts
CHANGED
|
@@ -12,7 +12,17 @@
|
|
|
12
12
|
import { registeredClassName } from './class-registry';
|
|
13
13
|
import { hasOwn, setOwn } from './utils/namespace';
|
|
14
14
|
import { deleteByPath, getByPath, setByPath } from './utils/object-path';
|
|
15
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
collectionSize,
|
|
17
|
+
extraKeys,
|
|
18
|
+
isAtomic,
|
|
19
|
+
isBoxed,
|
|
20
|
+
isTemporal,
|
|
21
|
+
mapEntries,
|
|
22
|
+
mapGet,
|
|
23
|
+
mapSet,
|
|
24
|
+
setMembers,
|
|
25
|
+
} from './utils/value-kinds';
|
|
16
26
|
|
|
17
27
|
export { isAtomic };
|
|
18
28
|
|
|
@@ -25,13 +35,13 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
|
25
35
|
}
|
|
26
36
|
|
|
27
37
|
/**
|
|
28
|
-
* `empty`, a new
|
|
29
|
-
* like it: an instance of a registered subclass keeps its class
|
|
30
|
-
* extra keys (#391), with the values `value` gives for them;
|
|
31
|
-
*
|
|
38
|
+
* `empty`, a new built-in (an Array, Map, Set, Date or RegExp) standing for
|
|
39
|
+
* `obj`, made like it: an instance of a registered subclass keeps its class
|
|
40
|
+
* and its extra keys (#391, #393), with the values `value` gives for them;
|
|
41
|
+
* other built-ins are plain. `made` gets it before its keys are filled, so
|
|
32
42
|
* that cycles through it find it.
|
|
33
43
|
*/
|
|
34
|
-
function
|
|
44
|
+
function builtinLike<C extends object>(
|
|
35
45
|
obj: object,
|
|
36
46
|
empty: C,
|
|
37
47
|
made: (copy: C) => void,
|
|
@@ -103,11 +113,17 @@ export function deepClone<T>(value: T, options: DeepCloneOptions = {}): T {
|
|
|
103
113
|
}
|
|
104
114
|
|
|
105
115
|
function cloneBuiltin(val: object): object | undefined {
|
|
106
|
-
|
|
116
|
+
// Read with the built-in getters: a subclass may override them
|
|
117
|
+
if (val instanceof Date) {
|
|
118
|
+
return builtin(val, new Date(Date.prototype.getTime.call(val)));
|
|
119
|
+
}
|
|
107
120
|
if (val instanceof RegExp) {
|
|
108
|
-
const copy = new RegExp(
|
|
121
|
+
const copy = new RegExp(
|
|
122
|
+
Reflect.get(RegExp.prototype, 'source', val) as string,
|
|
123
|
+
Reflect.get(RegExp.prototype, 'flags', val) as string,
|
|
124
|
+
);
|
|
109
125
|
copy.lastIndex = val.lastIndex; // the scanning cursor of g/y patterns
|
|
110
|
-
return
|
|
126
|
+
return builtin(val, copy);
|
|
111
127
|
}
|
|
112
128
|
if (val instanceof ArrayBuffer) return keep(val, val.slice(0));
|
|
113
129
|
if (ArrayBuffer.isView(val)) {
|
|
@@ -151,9 +167,9 @@ export function deepClone<T>(value: T, options: DeepCloneOptions = {}): T {
|
|
|
151
167
|
: copy;
|
|
152
168
|
}
|
|
153
169
|
|
|
154
|
-
/** `empty`, made the copy of the
|
|
155
|
-
const
|
|
156
|
-
|
|
170
|
+
/** `empty`, made the copy of the built-in `obj` (see builtinLike). */
|
|
171
|
+
const builtin = <C extends object>(obj: object, empty: C): C =>
|
|
172
|
+
builtinLike(
|
|
157
173
|
obj,
|
|
158
174
|
empty,
|
|
159
175
|
(copy) => keep(obj, copy),
|
|
@@ -168,30 +184,33 @@ export function deepClone<T>(value: T, options: DeepCloneOptions = {}): T {
|
|
|
168
184
|
|
|
169
185
|
if (Array.isArray(val)) {
|
|
170
186
|
// Holes stay holes
|
|
171
|
-
const arr =
|
|
187
|
+
const arr = builtin(obj, new Array(val.length) as unknown[]);
|
|
172
188
|
for (let i = 0; i < val.length; i++) {
|
|
173
189
|
if (i in val) arr[i] = clone(val[i]);
|
|
174
190
|
}
|
|
175
191
|
return arr;
|
|
176
192
|
}
|
|
177
193
|
|
|
178
|
-
//
|
|
194
|
+
// Read and filled with the built-in methods: a subclass may override
|
|
195
|
+
// them (see mapEntries)
|
|
179
196
|
if (val instanceof Map) {
|
|
180
|
-
const copy =
|
|
181
|
-
for (const [k, v] of val) {
|
|
197
|
+
const copy = builtin(obj, new Map());
|
|
198
|
+
for (const [k, v] of mapEntries(val)) {
|
|
182
199
|
Map.prototype.set.call(copy, settled(clone(k), k), clone(v));
|
|
183
200
|
}
|
|
184
201
|
return copy;
|
|
185
202
|
}
|
|
186
203
|
|
|
187
204
|
if (val instanceof Set) {
|
|
188
|
-
const copy =
|
|
189
|
-
for (const v of val)
|
|
205
|
+
const copy = builtin(obj, new Set());
|
|
206
|
+
for (const v of setMembers(val)) {
|
|
207
|
+
Set.prototype.add.call(copy, settled(clone(v), v));
|
|
208
|
+
}
|
|
190
209
|
return copy;
|
|
191
210
|
}
|
|
192
211
|
|
|
193
|
-
const
|
|
194
|
-
if (
|
|
212
|
+
const copied = cloneBuiltin(obj);
|
|
213
|
+
if (copied !== undefined) return copied;
|
|
195
214
|
|
|
196
215
|
// A registered class instance keeps its class, and a plain object its
|
|
197
216
|
// prototype (which may be null). An instance of an unregistered class
|
|
@@ -332,13 +351,13 @@ export function shareEqual<T>(prev: unknown, curr: T): T {
|
|
|
332
351
|
/** What the result holds at `key` of `c`. */
|
|
333
352
|
const shareKey = (key: string) =>
|
|
334
353
|
share(hasOwn(pr, key) ? pr[key] : undefined, cr[key]);
|
|
335
|
-
/** `empty`, made the result for the collection `c` (see
|
|
354
|
+
/** `empty`, made the result for the collection `c` (see builtinLike). */
|
|
336
355
|
const collection = <C extends object>(empty: C): C =>
|
|
337
|
-
|
|
356
|
+
builtinLike(c, empty, (out) => done.set(c, out), shareKey);
|
|
338
357
|
if (c instanceof Map && p instanceof Map) {
|
|
339
358
|
const out = collection(new Map());
|
|
340
|
-
for (const [k, v] of c) {
|
|
341
|
-
Map.prototype.set.call(out, fresh(k), share(p
|
|
359
|
+
for (const [k, v] of mapEntries(c)) {
|
|
360
|
+
Map.prototype.set.call(out, fresh(k), share(mapGet(p, k), v));
|
|
342
361
|
}
|
|
343
362
|
return out;
|
|
344
363
|
}
|
|
@@ -381,9 +400,20 @@ function equalBuiltin(
|
|
|
381
400
|
b: object,
|
|
382
401
|
assumed: Pairs,
|
|
383
402
|
): boolean | undefined {
|
|
384
|
-
if (a instanceof Date)
|
|
403
|
+
if (a instanceof Date) {
|
|
404
|
+
const time = Date.prototype.getTime;
|
|
405
|
+
return (
|
|
406
|
+
Object.is(time.call(a), time.call(b as Date)) &&
|
|
407
|
+
equalExtraKeys(a, b, assumed)
|
|
408
|
+
);
|
|
409
|
+
}
|
|
385
410
|
if (a instanceof RegExp) {
|
|
386
|
-
|
|
411
|
+
const text = RegExp.prototype.toString;
|
|
412
|
+
return (
|
|
413
|
+
text.call(a) === text.call(b as RegExp) &&
|
|
414
|
+
a.lastIndex === (b as RegExp).lastIndex &&
|
|
415
|
+
equalExtraKeys(a, b, assumed)
|
|
416
|
+
);
|
|
387
417
|
}
|
|
388
418
|
if (ArrayBuffer.isView(a)) {
|
|
389
419
|
// A view is its place in its backing buffer, and the whole buffer (which
|
|
@@ -435,20 +465,22 @@ function equalKeys(
|
|
|
435
465
|
return true;
|
|
436
466
|
}
|
|
437
467
|
|
|
438
|
-
/** Built-in
|
|
439
|
-
const
|
|
468
|
+
/** Built-in prototypes (see builtinLike), whose instances hold no extra keys. */
|
|
469
|
+
const BUILTIN_PROTOS: ReadonlySet<object | null> = new Set([
|
|
440
470
|
Array.prototype,
|
|
441
471
|
Map.prototype,
|
|
442
472
|
Set.prototype,
|
|
473
|
+
Date.prototype,
|
|
474
|
+
RegExp.prototype,
|
|
443
475
|
]);
|
|
444
476
|
|
|
445
477
|
/**
|
|
446
|
-
* Equality of the extra keys (see extraKeys) of two
|
|
478
|
+
* Equality of the extra keys (see extraKeys) of two built-ins of one
|
|
447
479
|
* class: those of a subclass instance are part of its value.
|
|
448
480
|
*/
|
|
449
481
|
function equalExtraKeys(a: object, b: object, assumed: Pairs): boolean {
|
|
450
482
|
return (
|
|
451
|
-
|
|
483
|
+
BUILTIN_PROTOS.has(Object.getPrototypeOf(a) as object | null) ||
|
|
452
484
|
equalKeys(a, b, assumed, extraKeys)
|
|
453
485
|
);
|
|
454
486
|
}
|
|
@@ -470,9 +502,9 @@ function equal(a: unknown, b: unknown, assumed: Pairs): boolean {
|
|
|
470
502
|
|
|
471
503
|
if (a instanceof Map || a instanceof Set) {
|
|
472
504
|
const bc = b as Map<unknown, unknown> | Set<unknown>;
|
|
473
|
-
if (a
|
|
474
|
-
const ai = a
|
|
475
|
-
const bi = bc
|
|
505
|
+
if (collectionSize(a) !== collectionSize(bc)) return false;
|
|
506
|
+
const ai = a instanceof Map ? mapEntries(a) : setMembers(a);
|
|
507
|
+
const bi = bc instanceof Map ? mapEntries(bc) : setMembers(bc);
|
|
476
508
|
for (
|
|
477
509
|
let x = ai.next(), y = bi.next();
|
|
478
510
|
!x.done;
|
|
@@ -762,13 +794,15 @@ function entryChildren(value: object): [string, unknown][] | undefined {
|
|
|
762
794
|
const children: [string, unknown][] = [];
|
|
763
795
|
if (value instanceof Map) {
|
|
764
796
|
let index = 0;
|
|
765
|
-
for (const [k, v] of value) {
|
|
797
|
+
for (const [k, v] of mapEntries(value)) {
|
|
766
798
|
children.push([entryKey('k', index), k], [entryKey('v', index), v]);
|
|
767
799
|
index++;
|
|
768
800
|
}
|
|
769
801
|
} else if (value instanceof Set) {
|
|
770
802
|
let index = 0;
|
|
771
|
-
for (const member of value)
|
|
803
|
+
for (const member of setMembers(value)) {
|
|
804
|
+
children.push([entryKey('s', index++), member]);
|
|
805
|
+
}
|
|
772
806
|
} else if (value instanceof Error) {
|
|
773
807
|
for (const key of ['cause', 'errors'] as const) {
|
|
774
808
|
if (hasOwn(value, key)) {
|
|
@@ -819,8 +853,9 @@ function setEntryValue(
|
|
|
819
853
|
const holder = getByEntryPath(root, path.slice(0, -1));
|
|
820
854
|
const match = /^\0v(\d+)$/.exec(path[path.length - 1]!);
|
|
821
855
|
if (!(holder instanceof Map) || !match) return false;
|
|
822
|
-
const
|
|
823
|
-
|
|
856
|
+
const entry = [...mapEntries(holder)][Number(match[1])];
|
|
857
|
+
if (!entry) return false;
|
|
858
|
+
mapSet(holder, entry[0], value);
|
|
824
859
|
return true;
|
|
825
860
|
}
|
|
826
861
|
|
|
@@ -921,7 +956,8 @@ function aliasMoved(
|
|
|
921
956
|
if (xs && ys) {
|
|
922
957
|
if (Object.getPrototypeOf(x) !== Object.getPrototypeOf(y)) return false;
|
|
923
958
|
if (x instanceof Map || x instanceof Set) {
|
|
924
|
-
|
|
959
|
+
const ySize = collectionSize(y as Map<unknown, unknown>);
|
|
960
|
+
if (collectionSize(x) !== ySize) return false;
|
|
925
961
|
}
|
|
926
962
|
const held = new Map(ys);
|
|
927
963
|
for (const [segment, a] of xs) {
|
package/src/triggers.ts
CHANGED
|
@@ -294,10 +294,14 @@ export function reinitTriggerState(): void {
|
|
|
294
294
|
}
|
|
295
295
|
}
|
|
296
296
|
|
|
297
|
+
/**
|
|
298
|
+
* Forget every watcher and close every dialog on display, whoever opened it
|
|
299
|
+
* (restart): a `{dialog}` or menubar dialog of the story interface, which
|
|
300
|
+
* stays mounted, would otherwise stay open over the new game (#397).
|
|
301
|
+
*/
|
|
297
302
|
export function resetTriggers(): void {
|
|
298
303
|
triggers = [];
|
|
299
|
-
|
|
300
|
-
dialogHostCallbacks?.closeAll();
|
|
304
|
+
closeAllOpenDialogs();
|
|
301
305
|
}
|
|
302
306
|
|
|
303
307
|
export function subscribeTriggerDialogs(cb: () => void): () => void {
|
package/src/utils/stable-key.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { registeredClassName } from '../class-registry';
|
|
2
|
+
import { mapEntries, setMembers } from './value-kinds';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Content-derived string key for a value, used to remount components when
|
|
@@ -48,8 +49,11 @@ function keyOf(val: unknown, ancestors: object[]): string {
|
|
|
48
49
|
const depth = ancestors.indexOf(obj);
|
|
49
50
|
if (depth !== -1) return `<cycle ${ancestors.length - depth}>`;
|
|
50
51
|
|
|
51
|
-
|
|
52
|
-
if (val instanceof
|
|
52
|
+
// Read with the built-in methods: a subclass may override them
|
|
53
|
+
if (val instanceof Date) return `Date(${Date.prototype.getTime.call(val)})`;
|
|
54
|
+
if (val instanceof RegExp) {
|
|
55
|
+
return `RegExp(${RegExp.prototype.toString.call(val)}@${val.lastIndex})`;
|
|
56
|
+
}
|
|
53
57
|
|
|
54
58
|
ancestors.push(obj);
|
|
55
59
|
try {
|
|
@@ -60,13 +64,13 @@ function keyOf(val: unknown, ancestors: object[]): string {
|
|
|
60
64
|
return `[${Array.from(val, (v) => keyOf(v, ancestors)).join(',')}]`;
|
|
61
65
|
}
|
|
62
66
|
if (val instanceof Map) {
|
|
63
|
-
const entries = [...val].map(
|
|
67
|
+
const entries = [...mapEntries(val)].map(
|
|
64
68
|
([k, v]) => `[${keyOf(k, ancestors)},${keyOf(v, ancestors)}]`,
|
|
65
69
|
);
|
|
66
70
|
return `Map[${entries.join(',')}]`;
|
|
67
71
|
}
|
|
68
72
|
if (val instanceof Set) {
|
|
69
|
-
return `Set[${[...val].map((v) => keyOf(v, ancestors)).join(',')}]`;
|
|
73
|
+
return `Set[${[...setMembers(val)].map((v) => keyOf(v, ancestors)).join(',')}]`;
|
|
70
74
|
}
|
|
71
75
|
|
|
72
76
|
const record = obj as Record<string, unknown>;
|
package/src/utils/value-kinds.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// Kinds of story values that structural operations (clone, equality, merge)
|
|
2
2
|
// and property paths treat as a whole, by their content.
|
|
3
3
|
|
|
4
|
+
import { isDraft } from 'immer';
|
|
5
|
+
|
|
4
6
|
export const toStringTag = (value: object): string =>
|
|
5
7
|
Object.prototype.toString.call(value).slice(8, -1);
|
|
6
8
|
|
|
@@ -57,3 +59,43 @@ export function extraKeys(collection: object): string[] {
|
|
|
57
59
|
const keys = Object.keys(collection);
|
|
58
60
|
return Array.isArray(collection) ? keys.filter((k) => !isIndexKey(k)) : keys;
|
|
59
61
|
}
|
|
62
|
+
|
|
63
|
+
// A subclass of Map or Set may override how it is iterated, read or written
|
|
64
|
+
// (an inventory that iterates only the items on hand). What it holds is in
|
|
65
|
+
// its built-in slots, so structural operations read and write those with the
|
|
66
|
+
// built-in methods (#391, #394). An Immer draft keeps its entries in its own
|
|
67
|
+
// state instead, so it is read through its methods.
|
|
68
|
+
|
|
69
|
+
/** The entries of a Map, as the built-in iterator gives them. */
|
|
70
|
+
export function mapEntries<K, V>(map: Map<K, V>): MapIterator<[K, V]> {
|
|
71
|
+
return isDraft(map)
|
|
72
|
+
? map.entries()
|
|
73
|
+
: (Map.prototype.entries.call(map) as MapIterator<[K, V]>);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The members of a Set, as the built-in iterator gives them. */
|
|
77
|
+
export function setMembers<T>(set: Set<T>): SetIterator<T> {
|
|
78
|
+
return isDraft(set)
|
|
79
|
+
? set.values()
|
|
80
|
+
: (Set.prototype.values.call(set) as SetIterator<T>);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The number of entries of a Map or members of a Set. */
|
|
84
|
+
export function collectionSize(
|
|
85
|
+
collection: Map<unknown, unknown> | Set<unknown>,
|
|
86
|
+
) {
|
|
87
|
+
if (isDraft(collection)) return collection.size;
|
|
88
|
+
const proto = collection instanceof Map ? Map.prototype : Set.prototype;
|
|
89
|
+
return Reflect.get(proto, 'size', collection) as number;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The value a Map holds for `key`. */
|
|
93
|
+
export function mapGet<K, V>(map: Map<K, V>, key: K): V | undefined {
|
|
94
|
+
return isDraft(map) ? map.get(key) : Map.prototype.get.call(map, key);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Make a Map hold `value` for `key`. */
|
|
98
|
+
export function mapSet<K, V>(map: Map<K, V>, key: K, value: V): void {
|
|
99
|
+
if (isDraft(map)) map.set(key, value);
|
|
100
|
+
else Map.prototype.set.call(map, key, value);
|
|
101
|
+
}
|
package/types/index.d.ts
CHANGED
|
@@ -794,7 +794,9 @@ export interface StoryAPI {
|
|
|
794
794
|
/**
|
|
795
795
|
* Register a class so its instances keep their class through clones,
|
|
796
796
|
* history, saves and loads. A save refuses instances of classes that are
|
|
797
|
-
* not registered.
|
|
797
|
+
* not registered. A class may extend Array, Map, Set, Date, RegExp or
|
|
798
|
+
* Error; one extending another built-in whose value a save cannot hold (a
|
|
799
|
+
* typed array, URL, Promise...) throws.
|
|
798
800
|
*/
|
|
799
801
|
registerClass(name: string, ctor: new (...args: any[]) => any): void;
|
|
800
802
|
|