@rohal12/spindle 0.52.9 → 0.53.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 +2 -0
- package/dist/pkg/format.js +1 -1
- package/dist/pkg/headless.js +1731 -581
- package/package.json +5 -1
- package/src/class-registry.ts +252 -250
- package/src/components/App.tsx +2 -0
- package/src/components/RuntimeErrors.tsx +46 -0
- package/src/components/macros/Computed.tsx +5 -2
- package/src/components/macros/Do.tsx +3 -1
- package/src/components/macros/Goto.tsx +13 -3
- package/src/components/macros/MacroError.tsx +14 -7
- package/src/components/macros/SaveManager.tsx +2 -2
- package/src/runtime-errors.ts +65 -0
- package/src/saves/format.ts +199 -0
- package/src/saves/save-manager.ts +45 -63
- package/src/saves/types.ts +71 -88
- package/src/store.ts +167 -87
- package/src/structural.ts +266 -57
- package/src/styles.css +45 -0
- package/src/utils/object-path.ts +13 -12
- package/src/utils/stable-key.ts +2 -1
- package/src/utils/value-kinds.ts +45 -0
- package/types/index.d.ts +32 -8
package/src/structural.ts
CHANGED
|
@@ -2,12 +2,21 @@
|
|
|
2
2
|
// and merging the differences between two versions of a value. Mutation
|
|
3
3
|
// commits, history and save hooks all go through these, so they agree on
|
|
4
4
|
// what a change is.
|
|
5
|
+
//
|
|
6
|
+
// They handle the value types a save holds (see serialize() in
|
|
7
|
+
// class-registry.ts): plain and prototype-less objects, arrays (holes kept),
|
|
8
|
+
// registered class instances, Date, RegExp, Map, Set, typed arrays,
|
|
9
|
+
// ArrayBuffer, DataView, URL, URLSearchParams, boxed primitives, errors and
|
|
10
|
+
// Temporal values, with shared references and cycles.
|
|
5
11
|
|
|
6
12
|
import { registeredClassName } from './class-registry';
|
|
7
13
|
import { hasOwn, setOwn } from './utils/namespace';
|
|
8
14
|
import { deleteByPath, getByPath, setByPath } from './utils/object-path';
|
|
15
|
+
import { isAtomic, isBoxed, isTemporal } from './utils/value-kinds';
|
|
9
16
|
|
|
10
|
-
|
|
17
|
+
export { isAtomic };
|
|
18
|
+
|
|
19
|
+
// --- Value kinds ---
|
|
11
20
|
|
|
12
21
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
13
22
|
if (typeof value !== 'object' || value === null) return false;
|
|
@@ -15,6 +24,11 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
|
15
24
|
return proto === Object.prototype || proto === null;
|
|
16
25
|
}
|
|
17
26
|
|
|
27
|
+
/** Own keys of an error that are not enumerable but are data. */
|
|
28
|
+
const ERROR_HIDDEN_KEYS = ['message', 'cause', 'errors'] as const;
|
|
29
|
+
|
|
30
|
+
// --- Deep Clone ---
|
|
31
|
+
|
|
18
32
|
export interface DeepCloneOptions {
|
|
19
33
|
/**
|
|
20
34
|
* Return instances of unregistered classes (DOM nodes, promises, other
|
|
@@ -24,31 +38,85 @@ export interface DeepCloneOptions {
|
|
|
24
38
|
keepUnregistered?: boolean;
|
|
25
39
|
}
|
|
26
40
|
|
|
41
|
+
type TypedArrayCtor = new (
|
|
42
|
+
buffer: ArrayBufferLike,
|
|
43
|
+
byteOffset: number,
|
|
44
|
+
length: number,
|
|
45
|
+
) => ArrayBufferView;
|
|
46
|
+
|
|
27
47
|
export function deepClone<T>(value: T, options: DeepCloneOptions = {}): T {
|
|
28
48
|
const seen = new Map<object, object>();
|
|
29
49
|
|
|
50
|
+
/** Record `copy` as the copy of `obj` before its contents are copied. */
|
|
51
|
+
const keep = <C extends object>(obj: object, copy: C): C => {
|
|
52
|
+
seen.set(obj, copy);
|
|
53
|
+
return copy;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** Copy the own (enumerable) keys of `obj` into `copy`. */
|
|
57
|
+
function copyKeys(obj: object, copy: object): object {
|
|
58
|
+
for (const key of Object.keys(obj)) {
|
|
59
|
+
setOwn(copy, key, clone((obj as Record<string, unknown>)[key]));
|
|
60
|
+
}
|
|
61
|
+
return copy;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function cloneBuiltin(val: object): object | undefined {
|
|
65
|
+
if (val instanceof Date) return keep(val, new Date(val.getTime()));
|
|
66
|
+
if (val instanceof RegExp) {
|
|
67
|
+
return keep(val, new RegExp(val.source, val.flags));
|
|
68
|
+
}
|
|
69
|
+
if (val instanceof ArrayBuffer) return keep(val, val.slice(0));
|
|
70
|
+
if (ArrayBuffer.isView(val)) {
|
|
71
|
+
// The buffer through the copies, so views of one buffer share it
|
|
72
|
+
const buffer = clone(val.buffer) as ArrayBuffer;
|
|
73
|
+
if (val instanceof DataView) {
|
|
74
|
+
return keep(val, new DataView(buffer, val.byteOffset, val.byteLength));
|
|
75
|
+
}
|
|
76
|
+
const ctor = val.constructor as TypedArrayCtor;
|
|
77
|
+
const length = (val as unknown as { length: number }).length;
|
|
78
|
+
return keep(val, new ctor(buffer, val.byteOffset, length));
|
|
79
|
+
}
|
|
80
|
+
if (val instanceof URL) return keep(val, new URL(val.href));
|
|
81
|
+
if (val instanceof URLSearchParams) {
|
|
82
|
+
return keep(val, new URLSearchParams(val));
|
|
83
|
+
}
|
|
84
|
+
if (isBoxed(val)) return keep(val, Object((val as Number).valueOf()));
|
|
85
|
+
if (isTemporal(val)) return val;
|
|
86
|
+
if (val instanceof Error) {
|
|
87
|
+
// Keeps its class (built-in, or a registered or other subclass)
|
|
88
|
+
const copy = keep(val, Object.create(Object.getPrototypeOf(val)));
|
|
89
|
+
for (const key of [...ERROR_HIDDEN_KEYS, 'stack']) {
|
|
90
|
+
if (hasOwn(val, key)) {
|
|
91
|
+
Object.defineProperty(copy, key, {
|
|
92
|
+
value: clone((val as unknown as Record<string, unknown>)[key]),
|
|
93
|
+
writable: true,
|
|
94
|
+
configurable: true,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return copyKeys(val, copy);
|
|
99
|
+
}
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
|
|
30
103
|
function clone(val: unknown): unknown {
|
|
31
104
|
if (val === null || typeof val !== 'object') return val;
|
|
32
105
|
|
|
33
106
|
const obj = val as object;
|
|
34
107
|
if (seen.has(obj)) return seen.get(obj);
|
|
35
108
|
|
|
36
|
-
if (val instanceof Date) return new Date(val.getTime()) as unknown;
|
|
37
|
-
if (val instanceof RegExp)
|
|
38
|
-
return new RegExp(val.source, val.flags) as unknown;
|
|
39
|
-
|
|
40
109
|
if (Array.isArray(val)) {
|
|
41
|
-
|
|
42
|
-
|
|
110
|
+
// Holes stay holes
|
|
111
|
+
const arr = keep(obj, new Array(val.length) as unknown[]);
|
|
43
112
|
for (let i = 0; i < val.length; i++) {
|
|
44
|
-
arr[i] = clone(val[i]);
|
|
113
|
+
if (i in val) arr[i] = clone(val[i]);
|
|
45
114
|
}
|
|
46
115
|
return arr;
|
|
47
116
|
}
|
|
48
117
|
|
|
49
118
|
if (val instanceof Map) {
|
|
50
|
-
const copy = new Map();
|
|
51
|
-
seen.set(obj, copy);
|
|
119
|
+
const copy = keep(obj, new Map());
|
|
52
120
|
for (const [k, v] of val) {
|
|
53
121
|
copy.set(clone(k), clone(v));
|
|
54
122
|
}
|
|
@@ -56,30 +124,29 @@ export function deepClone<T>(value: T, options: DeepCloneOptions = {}): T {
|
|
|
56
124
|
}
|
|
57
125
|
|
|
58
126
|
if (val instanceof Set) {
|
|
59
|
-
const copy = new Set();
|
|
60
|
-
seen.set(obj, copy);
|
|
127
|
+
const copy = keep(obj, new Set());
|
|
61
128
|
for (const v of val) {
|
|
62
129
|
copy.add(clone(v));
|
|
63
130
|
}
|
|
64
131
|
return copy;
|
|
65
132
|
}
|
|
66
133
|
|
|
134
|
+
const builtin = cloneBuiltin(obj);
|
|
135
|
+
if (builtin !== undefined) return builtin;
|
|
136
|
+
|
|
67
137
|
// A registered class instance keeps its class, and a plain object its
|
|
68
138
|
// prototype (which may be null). An instance of an unregistered class
|
|
69
139
|
// is treated as plain, unless it is kept by reference.
|
|
70
140
|
const keepsProto =
|
|
71
141
|
registeredClassName(obj) !== undefined || isPlainObject(val);
|
|
72
142
|
if (!keepsProto && options.keepUnregistered) return val;
|
|
73
|
-
const copy = (
|
|
143
|
+
const copy = keep(
|
|
144
|
+
obj,
|
|
74
145
|
keepsProto
|
|
75
146
|
? Object.create(Object.getPrototypeOf(obj) as object | null)
|
|
76
|
-
: {}
|
|
77
|
-
)
|
|
78
|
-
|
|
79
|
-
for (const key of Object.keys(obj)) {
|
|
80
|
-
setOwn(copy, key, clone((obj as Record<string, unknown>)[key]));
|
|
81
|
-
}
|
|
82
|
-
return copy;
|
|
147
|
+
: {},
|
|
148
|
+
);
|
|
149
|
+
return copyKeys(obj, copy);
|
|
83
150
|
}
|
|
84
151
|
|
|
85
152
|
return clone(value) as T;
|
|
@@ -88,27 +155,179 @@ export function deepClone<T>(value: T, options: DeepCloneOptions = {}): T {
|
|
|
88
155
|
// --- Deep Equal ---
|
|
89
156
|
|
|
90
157
|
/**
|
|
91
|
-
* Structural equality over the value types deepClone() supports
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
* are compared by length and index
|
|
95
|
-
*
|
|
158
|
+
* Structural equality over the value types deepClone() supports, nested at
|
|
159
|
+
* any depth and with cycles. Objects must have the same prototype. Map and
|
|
160
|
+
* Set entries are compared in insertion order, so reordering one is a
|
|
161
|
+
* change. Arrays are compared by length and index; a hole differs from an
|
|
162
|
+
* undefined element. Errors are compared by class, message, cause, errors
|
|
163
|
+
* and own keys (not their stack).
|
|
96
164
|
*/
|
|
97
165
|
export function deepEqual(a: unknown, b: unknown): boolean {
|
|
98
|
-
return equal(a, b,
|
|
166
|
+
return equal(a, b, loosePairs());
|
|
99
167
|
}
|
|
100
168
|
|
|
101
169
|
/**
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
170
|
+
* Called on meeting the pair (a, b) of objects to compare: true when the
|
|
171
|
+
* pair is already being compared (a cycle: assume it equal), false when it
|
|
172
|
+
* cannot be equal, undefined to compare it.
|
|
105
173
|
*/
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
174
|
+
type Pairs = ((a: object, b: object) => boolean | undefined) & {
|
|
175
|
+
/** Identical objects are paired and compared too, not taken as equal. */
|
|
176
|
+
strict?: boolean;
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Pairs for deepEqual(). Every object may pair with several others, since
|
|
181
|
+
* cycles of different lengths can still unfold to the same value, and
|
|
182
|
+
* shared references may equal distinct copies.
|
|
183
|
+
*/
|
|
184
|
+
function loosePairs(): Pairs {
|
|
185
|
+
const assumed = new Map<object, Set<object>>();
|
|
186
|
+
return (a, b) => {
|
|
187
|
+
const pairs = assumed.get(a);
|
|
188
|
+
if (pairs?.has(b)) return true;
|
|
189
|
+
if (pairs) pairs.add(b);
|
|
190
|
+
else assumed.set(a, new Set([b]));
|
|
191
|
+
return undefined;
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Pairs that also require the same reference structure: each object of one
|
|
197
|
+
* value pairs with exactly one of the other, so shared references and
|
|
198
|
+
* cycles are shared at the same places.
|
|
199
|
+
*/
|
|
200
|
+
function strictPairs(): Pairs {
|
|
201
|
+
const ab = new Map<object, object>();
|
|
202
|
+
const ba = new Map<object, object>();
|
|
203
|
+
const pairs: Pairs = (a, b) => {
|
|
204
|
+
const known = ab.get(a);
|
|
205
|
+
if (known !== undefined) return known === b;
|
|
206
|
+
if (ba.has(b)) return false;
|
|
207
|
+
ab.set(a, b);
|
|
208
|
+
ba.set(b, a);
|
|
209
|
+
return undefined;
|
|
210
|
+
};
|
|
211
|
+
pairs.strict = true;
|
|
212
|
+
return pairs;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Whether `a` and `b` are equal as deepEqual() has it and also share
|
|
217
|
+
* references at the same places (a save of one loads as the other).
|
|
218
|
+
*/
|
|
219
|
+
export function deepEqualStrict(a: unknown, b: unknown): boolean {
|
|
220
|
+
return equal(a, b, strictPairs());
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* `curr` with each part that equals (see deepEqualStrict) the part of
|
|
225
|
+
* `prev` at the same place replaced by `prev`'s: the history moments of a
|
|
226
|
+
* save then share what did not change between them, which a save stores
|
|
227
|
+
* once. Containers on the way to a change are copied; neither value is
|
|
228
|
+
* changed. Shared references and cycles within `curr` stay so.
|
|
229
|
+
*/
|
|
230
|
+
export function shareEqual<T>(prev: unknown, curr: T): T {
|
|
231
|
+
const done = new Map<object, unknown>();
|
|
232
|
+
function share(p: unknown, c: unknown): unknown {
|
|
233
|
+
if (p === c || !isObjectValue(c) || !isObjectValue(p)) return c;
|
|
234
|
+
if (done.has(c)) return done.get(c);
|
|
235
|
+
if (deepEqualStrict(p, c)) {
|
|
236
|
+
done.set(c, p);
|
|
237
|
+
return p;
|
|
238
|
+
}
|
|
239
|
+
if (c instanceof Map && p instanceof Map) {
|
|
240
|
+
const out = new Map();
|
|
241
|
+
done.set(c, out);
|
|
242
|
+
for (const [k, v] of c) out.set(k, p.has(k) ? share(p.get(k), v) : v);
|
|
243
|
+
return out;
|
|
244
|
+
}
|
|
245
|
+
if (Array.isArray(c) && Array.isArray(p)) {
|
|
246
|
+
const out = new Array(c.length) as unknown[];
|
|
247
|
+
done.set(c, out);
|
|
248
|
+
for (let i = 0; i < c.length; i++) {
|
|
249
|
+
if (i in c) out[i] = i < p.length ? share(p[i], c[i]) : c[i];
|
|
250
|
+
}
|
|
251
|
+
return out;
|
|
252
|
+
}
|
|
253
|
+
if (mergesWith(p, c, false)) {
|
|
254
|
+
const out = Object.create(Object.getPrototypeOf(c) as object | null);
|
|
255
|
+
done.set(c, out);
|
|
256
|
+
const cr = c as Record<string, unknown>;
|
|
257
|
+
for (const key of Object.keys(cr)) {
|
|
258
|
+
setOwn(out, key, hasOwn(p, key) ? share(p[key], cr[key]) : cr[key]);
|
|
259
|
+
}
|
|
260
|
+
return out;
|
|
261
|
+
}
|
|
262
|
+
done.set(c, c);
|
|
263
|
+
return c;
|
|
264
|
+
}
|
|
265
|
+
return share(prev, curr) as T;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const isObjectValue = (v: unknown): v is object =>
|
|
269
|
+
typeof v === 'object' && v !== null;
|
|
270
|
+
|
|
271
|
+
function equalBytes(a: ArrayBufferView | ArrayBuffer, b: typeof a): boolean {
|
|
272
|
+
const x = ArrayBuffer.isView(a)
|
|
273
|
+
? new Uint8Array(a.buffer, a.byteOffset, a.byteLength)
|
|
274
|
+
: new Uint8Array(a);
|
|
275
|
+
const y = ArrayBuffer.isView(b)
|
|
276
|
+
? new Uint8Array(b.buffer, b.byteOffset, b.byteLength)
|
|
277
|
+
: new Uint8Array(b as ArrayBuffer);
|
|
278
|
+
if (x.length !== y.length) return false;
|
|
279
|
+
for (let i = 0; i < x.length; i++) if (x[i] !== y[i]) return false;
|
|
280
|
+
return true;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Equality of the content of atomic built-ins (see isAtomic) but Map/Set. */
|
|
284
|
+
function equalBuiltin(
|
|
285
|
+
a: object,
|
|
286
|
+
b: object,
|
|
287
|
+
assumed: Pairs,
|
|
288
|
+
): boolean | undefined {
|
|
289
|
+
if (a instanceof Date) return Object.is(a.getTime(), (b as Date).getTime());
|
|
290
|
+
if (a instanceof RegExp) return String(a) === String(b);
|
|
291
|
+
if (ArrayBuffer.isView(a) || a instanceof ArrayBuffer) {
|
|
292
|
+
return equalBytes(a, b as ArrayBuffer);
|
|
293
|
+
}
|
|
294
|
+
if (a instanceof URL || a instanceof URLSearchParams) {
|
|
295
|
+
return String(a) === String(b);
|
|
296
|
+
}
|
|
297
|
+
if (isBoxed(a)) {
|
|
298
|
+
return Object.is((a as Number).valueOf(), (b as Number).valueOf());
|
|
299
|
+
}
|
|
300
|
+
if (isTemporal(a)) return String(a) === String(b);
|
|
301
|
+
if (a instanceof Error) {
|
|
302
|
+
return (
|
|
303
|
+
ERROR_HIDDEN_KEYS.every(
|
|
304
|
+
(key) =>
|
|
305
|
+
hasOwn(a, key) === hasOwn(b, key) &&
|
|
306
|
+
equal(
|
|
307
|
+
(a as unknown as Record<string, unknown>)[key],
|
|
308
|
+
(b as unknown as Record<string, unknown>)[key],
|
|
309
|
+
assumed,
|
|
310
|
+
),
|
|
311
|
+
) && equalKeys(a, b, assumed)
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
return undefined;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function equalKeys(a: object, b: object, assumed: Pairs): boolean {
|
|
318
|
+
const ao = a as Record<string, unknown>;
|
|
319
|
+
const bo = b as Record<string, unknown>;
|
|
320
|
+
const keys = Object.keys(ao);
|
|
321
|
+
if (keys.length !== Object.keys(bo).length) return false;
|
|
322
|
+
for (const key of keys) {
|
|
323
|
+
if (!hasOwn(bo, key) || !equal(ao[key], bo[key], assumed)) return false;
|
|
324
|
+
}
|
|
325
|
+
return true;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** `assumed` tracks the pairs of objects met (see Pairs). */
|
|
329
|
+
function equal(a: unknown, b: unknown, assumed: Pairs): boolean {
|
|
330
|
+
if (Object.is(a, b) && !(assumed.strict && isObjectValue(a))) return true;
|
|
112
331
|
if (
|
|
113
332
|
a === null ||
|
|
114
333
|
b === null ||
|
|
@@ -118,13 +337,9 @@ function equal(
|
|
|
118
337
|
return false;
|
|
119
338
|
}
|
|
120
339
|
if (Object.getPrototypeOf(a) !== Object.getPrototypeOf(b)) return false;
|
|
121
|
-
const
|
|
122
|
-
if (
|
|
123
|
-
if (pairs) pairs.add(b);
|
|
124
|
-
else assumed.set(a, new Set([b]));
|
|
340
|
+
const met = assumed(a, b);
|
|
341
|
+
if (met !== undefined) return met;
|
|
125
342
|
|
|
126
|
-
if (a instanceof Date) return Object.is(a.getTime(), (b as Date).getTime());
|
|
127
|
-
if (a instanceof RegExp) return String(a) === String(b);
|
|
128
343
|
if (a instanceof Map || a instanceof Set) {
|
|
129
344
|
const bc = b as Map<unknown, unknown> | Set<unknown>;
|
|
130
345
|
if (a.size !== bc.size) return false;
|
|
@@ -140,42 +355,36 @@ function equal(
|
|
|
140
355
|
return true;
|
|
141
356
|
}
|
|
142
357
|
|
|
358
|
+
const builtin = equalBuiltin(a, b, assumed);
|
|
359
|
+
if (builtin !== undefined) return builtin;
|
|
360
|
+
|
|
143
361
|
if (Array.isArray(a)) {
|
|
144
362
|
const bc = b as unknown[];
|
|
145
363
|
if (a.length !== bc.length) return false;
|
|
146
364
|
for (let i = 0; i < a.length; i++) {
|
|
147
|
-
if (!equal(a[i], bc[i], assumed)) return false;
|
|
365
|
+
if (i in a !== i in bc || !equal(a[i], bc[i], assumed)) return false;
|
|
148
366
|
}
|
|
149
367
|
return true;
|
|
150
368
|
}
|
|
151
369
|
|
|
152
|
-
|
|
153
|
-
const bo = b as Record<string, unknown>;
|
|
154
|
-
const keys = Object.keys(ao);
|
|
155
|
-
if (keys.length !== Object.keys(bo).length) return false;
|
|
156
|
-
for (const key of keys) {
|
|
157
|
-
if (!hasOwn(bo, key) || !equal(ao[key], bo[key], assumed)) return false;
|
|
158
|
-
}
|
|
159
|
-
return true;
|
|
370
|
+
return equalKeys(a, b, assumed);
|
|
160
371
|
}
|
|
161
372
|
|
|
162
373
|
// --- Structural diff ---
|
|
163
374
|
|
|
164
375
|
/**
|
|
165
376
|
* Objects merged property by property: plain objects and (registered)
|
|
166
|
-
* class instances. Arrays
|
|
167
|
-
*
|
|
168
|
-
* merge by (a shift moves
|
|
377
|
+
* class instances. Arrays and atomic built-ins (Map, Set, Date, typed
|
|
378
|
+
* arrays, errors..., see isAtomic) are values that are replaced as a whole,
|
|
379
|
+
* since their elements have no stable identity to merge by (a shift moves
|
|
380
|
+
* every index) or their content is not in their keys.
|
|
169
381
|
*/
|
|
170
382
|
export function isMergeable(value: unknown): value is Record<string, unknown> {
|
|
171
383
|
return (
|
|
172
384
|
typeof value === 'object' &&
|
|
173
385
|
value !== null &&
|
|
174
386
|
!Array.isArray(value) &&
|
|
175
|
-
!(value
|
|
176
|
-
!(value instanceof Set) &&
|
|
177
|
-
!(value instanceof Date) &&
|
|
178
|
-
!(value instanceof RegExp)
|
|
387
|
+
!isAtomic(value)
|
|
179
388
|
);
|
|
180
389
|
}
|
|
181
390
|
|
package/src/styles.css
CHANGED
|
@@ -118,6 +118,51 @@ tw-storydata {
|
|
|
118
118
|
background: rgba(239, 83, 80, 0.1);
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
/* Runtime errors shown on the page (see src/components/RuntimeErrors.tsx) */
|
|
122
|
+
.spindle-error-banners {
|
|
123
|
+
position: fixed;
|
|
124
|
+
top: 0.75em;
|
|
125
|
+
left: 50%;
|
|
126
|
+
z-index: 300;
|
|
127
|
+
display: flex;
|
|
128
|
+
flex-direction: column;
|
|
129
|
+
gap: 0.5em;
|
|
130
|
+
width: min(42em, calc(100% - 1.5em));
|
|
131
|
+
transform: translateX(-50%);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
.spindle-error-banner {
|
|
135
|
+
position: relative;
|
|
136
|
+
padding: 0.75em 2.75em 0.75em 1em;
|
|
137
|
+
border: 1px solid #ef5350;
|
|
138
|
+
border-radius: 4px;
|
|
139
|
+
background: #2b1a1e;
|
|
140
|
+
color: #ffcdd2;
|
|
141
|
+
font-size: 0.9em;
|
|
142
|
+
line-height: 1.5;
|
|
143
|
+
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.5);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
.spindle-error-banner-message {
|
|
147
|
+
font-family: monospace;
|
|
148
|
+
color: #ef9a9a;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
.spindle-error-banner-dismiss {
|
|
152
|
+
position: absolute;
|
|
153
|
+
top: 0.5em;
|
|
154
|
+
right: 0.5em;
|
|
155
|
+
background: transparent;
|
|
156
|
+
border: none;
|
|
157
|
+
color: inherit;
|
|
158
|
+
font-size: 1em;
|
|
159
|
+
cursor: pointer;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
.spindle-error-banner-dismiss:hover {
|
|
163
|
+
color: #fff;
|
|
164
|
+
}
|
|
165
|
+
|
|
121
166
|
.loading {
|
|
122
167
|
text-align: center;
|
|
123
168
|
padding: 2em;
|
package/src/utils/object-path.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { isDraft } from 'immer';
|
|
1
|
+
import { current as draftState, isDraft } from 'immer';
|
|
2
2
|
import { hasOwn } from './namespace';
|
|
3
|
+
import { atomicName } from './value-kinds';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Traverse dot-path segments on an object and return the nested value.
|
|
@@ -29,22 +30,13 @@ const isArrayKey = (key: string): boolean =>
|
|
|
29
30
|
* Built-ins whose content is not their properties: a property written into
|
|
30
31
|
* one would be dropped by clones and saves (and by Immer, for Map and Set).
|
|
31
32
|
*/
|
|
32
|
-
const builtinName =
|
|
33
|
-
value instanceof Map
|
|
34
|
-
? 'a Map'
|
|
35
|
-
: value instanceof Set
|
|
36
|
-
? 'a Set'
|
|
37
|
-
: value instanceof Date
|
|
38
|
-
? 'a Date'
|
|
39
|
-
: value instanceof RegExp
|
|
40
|
-
? 'a RegExp'
|
|
41
|
-
: undefined;
|
|
33
|
+
const builtinName = atomicName;
|
|
42
34
|
|
|
43
35
|
/**
|
|
44
36
|
* Shallow copy that keeps the prototype, so a registered class instance
|
|
45
37
|
* stays an instance of its class (with the same own keys deepClone copies).
|
|
46
38
|
*/
|
|
47
|
-
function shallowCopy(value: object): Record<string, unknown> {
|
|
39
|
+
export function shallowCopy(value: object): Record<string, unknown> {
|
|
48
40
|
const copy = Array.isArray(value)
|
|
49
41
|
? []
|
|
50
42
|
: (Object.create(Object.getPrototypeOf(value) as object | null) as object);
|
|
@@ -120,6 +112,15 @@ export function deleteByPath(
|
|
|
120
112
|
return;
|
|
121
113
|
}
|
|
122
114
|
const parent = walkToParent(root, segments, false);
|
|
115
|
+
if (Array.isArray(parent) && isDraft(parent) && segments.length > 1) {
|
|
116
|
+
// Immer turns `delete` of an array element into writing undefined: to
|
|
117
|
+
// leave a hole, as `delete` does, put in a copy of the array with one
|
|
118
|
+
const copy = (draftState(parent) as unknown[]).slice();
|
|
119
|
+
delete copy[Number(last)];
|
|
120
|
+
const holder = walkToParent(root, segments.slice(0, -1), false);
|
|
121
|
+
holder[segments[segments.length - 2]!] = copy;
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
123
124
|
delete parent[last];
|
|
124
125
|
}
|
|
125
126
|
|
package/src/utils/stable-key.ts
CHANGED
|
@@ -55,7 +55,8 @@ function keyOf(val: unknown, ancestors: object[]): string {
|
|
|
55
55
|
try {
|
|
56
56
|
if (Array.isArray(val)) {
|
|
57
57
|
// Array.from reads a hole as undefined (map would skip it, giving
|
|
58
|
-
// `[,]` the key of `[]`)
|
|
58
|
+
// `[,]` the key of `[]`): as JSON.stringify, a key does not tell a
|
|
59
|
+
// hole from an undefined element (deepEqual does)
|
|
59
60
|
return `[${Array.from(val, (v) => keyOf(v, ancestors)).join(',')}]`;
|
|
60
61
|
}
|
|
61
62
|
if (val instanceof Map) {
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Kinds of story values that structural operations (clone, equality, merge)
|
|
2
|
+
// and property paths treat as a whole, by their content.
|
|
3
|
+
|
|
4
|
+
export const toStringTag = (value: object): string =>
|
|
5
|
+
Object.prototype.toString.call(value).slice(8, -1);
|
|
6
|
+
|
|
7
|
+
/** Temporal values are immutable: copies may share them. */
|
|
8
|
+
export const isTemporal = (value: object): boolean =>
|
|
9
|
+
toStringTag(value).startsWith('Temporal.');
|
|
10
|
+
|
|
11
|
+
/** new Number(), new String(), new Boolean(), Object(1n). */
|
|
12
|
+
export const isBoxed = (value: object): boolean =>
|
|
13
|
+
value instanceof Number ||
|
|
14
|
+
value instanceof String ||
|
|
15
|
+
value instanceof Boolean ||
|
|
16
|
+
toStringTag(value) === 'BigInt';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Values compared and copied as a whole, by their content: they have no
|
|
20
|
+
* own keys to merge by, or their keys are not the data (a property written
|
|
21
|
+
* into one would be dropped by clones and saves, and by Immer for Map and
|
|
22
|
+
* Set).
|
|
23
|
+
*/
|
|
24
|
+
export function isAtomic(value: object): boolean {
|
|
25
|
+
return (
|
|
26
|
+
value instanceof Date ||
|
|
27
|
+
value instanceof RegExp ||
|
|
28
|
+
value instanceof Map ||
|
|
29
|
+
value instanceof Set ||
|
|
30
|
+
ArrayBuffer.isView(value) ||
|
|
31
|
+
value instanceof ArrayBuffer ||
|
|
32
|
+
value instanceof URL ||
|
|
33
|
+
value instanceof URLSearchParams ||
|
|
34
|
+
value instanceof Error ||
|
|
35
|
+
isBoxed(value) ||
|
|
36
|
+
isTemporal(value)
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** "a Map", "an Error", ... for an atomic value (see isAtomic), else undefined. */
|
|
41
|
+
export function atomicName(value: object): string | undefined {
|
|
42
|
+
if (!isAtomic(value)) return undefined;
|
|
43
|
+
const tag = value instanceof Error ? 'Error' : toStringTag(value);
|
|
44
|
+
return `${/^[AEIOU]/.test(tag) ? 'an' : 'a'} ${tag}`;
|
|
45
|
+
}
|
package/types/index.d.ts
CHANGED
|
@@ -575,7 +575,18 @@ export interface SaveMeta {
|
|
|
575
575
|
*/
|
|
576
576
|
export interface SaveRecord {
|
|
577
577
|
meta: SaveMeta;
|
|
578
|
-
payload:
|
|
578
|
+
/** The payload as stored: serialized in one piece, with its format version. */
|
|
579
|
+
payload: EncodedPayload;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* A save payload as stored. `data` is opaque serialized text; read it with
|
|
584
|
+
* the Story API (loading a save), not directly.
|
|
585
|
+
*/
|
|
586
|
+
export interface EncodedPayload {
|
|
587
|
+
/** The save format version `data` was written in. */
|
|
588
|
+
formatVersion: number;
|
|
589
|
+
data: string;
|
|
579
590
|
}
|
|
580
591
|
|
|
581
592
|
/**
|
|
@@ -584,7 +595,8 @@ export interface SaveRecord {
|
|
|
584
595
|
* @see {@link ../../src/saves/types.ts} for the implementation.
|
|
585
596
|
*/
|
|
586
597
|
export interface SaveExport {
|
|
587
|
-
version
|
|
598
|
+
/** The save format version of the export. */
|
|
599
|
+
formatVersion: number;
|
|
588
600
|
/** IFID of the story the save belongs to. Imports into other stories are rejected. */
|
|
589
601
|
ifid: string;
|
|
590
602
|
/** ISO 8601 timestamp of the export. */
|
|
@@ -614,13 +626,18 @@ export interface StoryAPI {
|
|
|
614
626
|
set(name: string, value: unknown): void;
|
|
615
627
|
set(vars: Record<string, unknown>): void;
|
|
616
628
|
|
|
617
|
-
/**
|
|
629
|
+
/**
|
|
630
|
+
* Navigate to a passage by name. Writes the session: if the variables hold
|
|
631
|
+
* a value a save cannot hold (a function, an instance of an unregistered
|
|
632
|
+
* class, a unique symbol), the navigation completes and then throws an
|
|
633
|
+
* error naming the variable.
|
|
634
|
+
*/
|
|
618
635
|
goto(passageName: string): void;
|
|
619
636
|
|
|
620
|
-
/** Go back one step in history. */
|
|
637
|
+
/** Go back one step in history. Throws like `goto()`. */
|
|
621
638
|
back(): void;
|
|
622
639
|
|
|
623
|
-
/** Go forward one step in history. */
|
|
640
|
+
/** Go forward one step in history. Throws like `goto()`. */
|
|
624
641
|
forward(): void;
|
|
625
642
|
|
|
626
643
|
/** Restart the story from the beginning. */
|
|
@@ -629,7 +646,9 @@ export interface StoryAPI {
|
|
|
629
646
|
/**
|
|
630
647
|
* Save the current state. Pass `slot` for a named save, `custom` for metadata.
|
|
631
648
|
* Resolves once the save is persisted (after `aftersave` handlers ran and
|
|
632
|
-
* `hasSave(slot)` is true); rejects if persisting fails
|
|
649
|
+
* `hasSave(slot)` is true); rejects if persisting fails, or if the state
|
|
650
|
+
* holds a value a save cannot hold (a function, an instance of an
|
|
651
|
+
* unregistered class, a unique symbol, a symbol key), naming it.
|
|
633
652
|
*/
|
|
634
653
|
save(slot?: string, custom?: Record<string, unknown>): Promise<void>;
|
|
635
654
|
|
|
@@ -638,7 +657,8 @@ export interface StoryAPI {
|
|
|
638
657
|
* playthrough, in call order: a save issued after the load belongs to it.
|
|
639
658
|
* Resolves once the loaded state is applied (immediately if the slot is
|
|
640
659
|
* empty, without loading if a restart was issued after the load); rejects
|
|
641
|
-
* if loading fails.
|
|
660
|
+
* if loading fails, e.g. for a save holding an instance of a class that is
|
|
661
|
+
* not registered, or one from an incompatible save format version.
|
|
642
662
|
*/
|
|
643
663
|
load(slot?: string): Promise<void>;
|
|
644
664
|
|
|
@@ -744,7 +764,11 @@ export interface StoryAPI {
|
|
|
744
764
|
/** Check whether any dialog is currently open. */
|
|
745
765
|
isDialogOpen(): boolean;
|
|
746
766
|
|
|
747
|
-
/**
|
|
767
|
+
/**
|
|
768
|
+
* Register a class so its instances keep their class through clones,
|
|
769
|
+
* history, saves and loads. A save refuses instances of classes that are
|
|
770
|
+
* not registered.
|
|
771
|
+
*/
|
|
748
772
|
registerClass(name: string, ctor: new (...args: any[]) => any): void;
|
|
749
773
|
|
|
750
774
|
/** Register a custom macro. */
|