@rohal12/spindle 0.59.13 → 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 +817 -272
- package/dist/pkg/story-variables.js +330 -160
- package/package.json +1 -1
- package/src/class-registry.ts +213 -24
- package/src/components/Passage.tsx +21 -13
- package/src/components/PassageDialog.tsx +12 -2
- package/src/components/macros/Radiobutton.tsx +4 -1
- package/src/components/macros/WidgetInvocation.tsx +7 -2
- package/src/execute-mutation.ts +11 -0
- package/src/hooks/use-modal-focus.ts +46 -16
- package/src/interpolation.ts +25 -5
- package/src/markup/render.tsx +28 -5
- package/src/markup/spindle.peggy +16 -0
- package/src/store.ts +12 -2
- package/src/structural.ts +136 -36
- package/src/triggers.ts +6 -2
- package/src/utils/stable-key.ts +8 -4
- package/src/utils/value-kinds.ts +56 -0
- package/src/widgets/widget-def.ts +27 -15
- package/types/index.d.ts +3 -1
package/src/markup/render.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createContext, type RefObject } from 'preact';
|
|
2
|
-
import { useContext, useLayoutEffect, useRef } from 'preact/hooks';
|
|
2
|
+
import { useContext, useLayoutEffect, useRef, useState } from 'preact/hooks';
|
|
3
3
|
import { VarDisplay } from '../components/macros/VarDisplay';
|
|
4
4
|
import { ExprDisplay } from '../components/macros/ExprDisplay';
|
|
5
5
|
import { WidgetInvocation } from '../components/macros/WidgetInvocation';
|
|
@@ -9,6 +9,7 @@ import { markdownToHtml } from './markdown';
|
|
|
9
9
|
import { h } from 'preact';
|
|
10
10
|
import type { ASTNode, HtmlNode, MacroNode } from './ast';
|
|
11
11
|
import { useTextScope } from '../hooks/use-interpolate';
|
|
12
|
+
import type { WidgetChildren } from '../interpolation';
|
|
12
13
|
import { useRenderOptions } from '../hooks/use-render-options';
|
|
13
14
|
import {
|
|
14
15
|
hasInterpolation,
|
|
@@ -57,6 +58,22 @@ export const InlineContext = createContext(false);
|
|
|
57
58
|
*/
|
|
58
59
|
export const RawTextContext = createContext(false);
|
|
59
60
|
|
|
61
|
+
/**
|
|
62
|
+
* The view content is rendered in: '' for the story interface, and an id of
|
|
63
|
+
* its own for each passage and dialog on display (see useViewScope). The
|
|
64
|
+
* radiobuttons of a variable form a native group within their view only:
|
|
65
|
+
* one group across views would let a dialog's checked button uncheck the
|
|
66
|
+
* passage's when it mounts (#396).
|
|
67
|
+
*/
|
|
68
|
+
export const ViewScopeContext = createContext('');
|
|
69
|
+
|
|
70
|
+
let viewScopes = 0;
|
|
71
|
+
|
|
72
|
+
/** A new view scope (see ViewScopeContext), kept while mounted. */
|
|
73
|
+
export function useViewScope(): string {
|
|
74
|
+
return useState(() => `v${++viewScopes}`)[0];
|
|
75
|
+
}
|
|
76
|
+
|
|
60
77
|
/**
|
|
61
78
|
* True inside an `<svg>` element. SVG attributes are case-sensitive and have
|
|
62
79
|
* no live form properties, so they are set as written (see splitAttributes).
|
|
@@ -67,7 +84,7 @@ const SVG_NAMESPACE = 'http://www.w3.org/2000/svg';
|
|
|
67
84
|
|
|
68
85
|
/** Elements whose content is literal text, not markdown. */
|
|
69
86
|
const PREFORMATTED_ELEMENTS = new Set(['pre', 'textarea', 'style']);
|
|
70
|
-
export const WidgetChildrenContext = createContext<
|
|
87
|
+
export const WidgetChildrenContext = createContext<WidgetChildren | null>(null);
|
|
71
88
|
|
|
72
89
|
/**
|
|
73
90
|
* Components rendered for the non-text nodes of one renderNodes() call. Each
|
|
@@ -708,10 +725,16 @@ function HtmlNodeRenderer({ node }: { node: HtmlNode }) {
|
|
|
708
725
|
}
|
|
709
726
|
|
|
710
727
|
function ChildrenSlot() {
|
|
711
|
-
const
|
|
728
|
+
const children = useContext(WidgetChildrenContext);
|
|
712
729
|
const renderOptions = useRenderOptions();
|
|
713
|
-
if (!
|
|
714
|
-
|
|
730
|
+
if (!children) return null;
|
|
731
|
+
// The children render with the widget's locals, but a {@children} among
|
|
732
|
+
// them forwards the enclosing widget's children (#386).
|
|
733
|
+
return (
|
|
734
|
+
<WidgetChildrenContext.Provider value={children.outer}>
|
|
735
|
+
{renderNodes(children.nodes, renderOptions)}
|
|
736
|
+
</WidgetChildrenContext.Provider>
|
|
737
|
+
);
|
|
715
738
|
}
|
|
716
739
|
|
|
717
740
|
/**
|
package/src/markup/spindle.peggy
CHANGED
|
@@ -31,6 +31,11 @@ const BRANCH_PARENT = { elseif: 'if', else: 'if', case: 'switch', default: 'swit
|
|
|
31
31
|
const BRANCHING = new Set(['if', 'switch', 'timed']);
|
|
32
32
|
const LINK_SEPARATORS = [['|', false], ['->', false], ['<-', true]];
|
|
33
33
|
const NO_SELECTORS = {};
|
|
34
|
+
/**
|
|
35
|
+
* A CommonMark email autolink, `<` an address `>` (without braces, which
|
|
36
|
+
* are markup), as markdown reads one.
|
|
37
|
+
*/
|
|
38
|
+
const EMAIL_AUTOLINK = /^<[a-zA-Z0-9.!#$%&'*+/=?^_`|~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*>$/;
|
|
34
39
|
|
|
35
40
|
/**
|
|
36
41
|
* A link's display text and target, from its text `inner` (at `at` in the
|
|
@@ -235,6 +240,7 @@ Item
|
|
|
235
240
|
/ Escape
|
|
236
241
|
/ Comment
|
|
237
242
|
/ LinkLexeme
|
|
243
|
+
/ EmailAutolink
|
|
238
244
|
/ HtmlElement
|
|
239
245
|
/ VoidCloser
|
|
240
246
|
/ Value
|
|
@@ -294,6 +300,7 @@ TokenItem
|
|
|
294
300
|
/ Escape
|
|
295
301
|
/ Comment
|
|
296
302
|
/ LinkLexeme
|
|
303
|
+
/ EmailAutolink
|
|
297
304
|
/ &{ return !textMode; } tag:HtmlOpen { return tag; }
|
|
298
305
|
/ &{ return !textMode; } tag:HtmlClose { return VOID_TAGS.has(tag.lower) ? null : tag; }
|
|
299
306
|
/ Value
|
|
@@ -429,6 +436,15 @@ MacroName
|
|
|
429
436
|
|
|
430
437
|
// -------------------------------------------------------------------- HTML
|
|
431
438
|
|
|
439
|
+
/**
|
|
440
|
+
* An email autolink (`<support@example.com>`), text that markdown makes a
|
|
441
|
+
* link: not an element `<support>` with an attribute `@example.com` (#395).
|
|
442
|
+
*/
|
|
443
|
+
EmailAutolink
|
|
444
|
+
= value:$("<" [a-zA-Z0-9.!#$%&'*+/=?^_`|~-]+ "@" [a-zA-Z0-9.-]+ ">")
|
|
445
|
+
&{ return EMAIL_AUTOLINK.test(value); }
|
|
446
|
+
{ return lexeme('text', location(), { value }); }
|
|
447
|
+
|
|
432
448
|
/** A redundant closer of a void element (</br>), which is dropped. */
|
|
433
449
|
VoidCloser
|
|
434
450
|
= &{ return !textMode; } tag:HtmlClose &{ return VOID_TAGS.has(tag.lower); } { return null; }
|
package/src/store.ts
CHANGED
|
@@ -444,6 +444,16 @@ function startVariables(defaults: Record<string, unknown>): Namespace {
|
|
|
444
444
|
return initialVars;
|
|
445
445
|
}
|
|
446
446
|
|
|
447
|
+
/**
|
|
448
|
+
* Fresh transient variables from their declared defaults (init, restart,
|
|
449
|
+
* load). Transients are never saved, so an instance of an unregistered
|
|
450
|
+
* class (an engine, a library object) is kept as it is, as mutation code
|
|
451
|
+
* keeps it (#321): a copy would lose its class and methods (#398).
|
|
452
|
+
*/
|
|
453
|
+
function freshTransients(defaults: Record<string, unknown>): Namespace {
|
|
454
|
+
return createNamespace(deepClone(defaults, { keepUnregistered: true }));
|
|
455
|
+
}
|
|
456
|
+
|
|
447
457
|
/**
|
|
448
458
|
* Enter the start passage with `variables`, as the only history moment
|
|
449
459
|
* (init, restart). Call it inside a store update.
|
|
@@ -457,7 +467,7 @@ function enterStart(
|
|
|
457
467
|
state.currentPassage = passage;
|
|
458
468
|
state.navigationId++;
|
|
459
469
|
state.variables = variables;
|
|
460
|
-
state.transient =
|
|
470
|
+
state.transient = freshTransients(transientDefaults);
|
|
461
471
|
state.temporary = createNamespace();
|
|
462
472
|
state.history = [{ passage, timestamp: Date.now() }];
|
|
463
473
|
state.historyIndex = 0;
|
|
@@ -1719,7 +1729,7 @@ export const useStoryStore = create<StoryState>()(
|
|
|
1719
1729
|
state.visitCounts = createCounts(payload.visitCounts);
|
|
1720
1730
|
state.renderCounts = createCounts(payload.renderCounts);
|
|
1721
1731
|
state.temporary = createNamespace();
|
|
1722
|
-
state.transient =
|
|
1732
|
+
state.transient = freshTransients(get().transientDefaults);
|
|
1723
1733
|
});
|
|
1724
1734
|
|
|
1725
1735
|
// The watchers of the loaded game (a payload without any, from an
|
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
|
|
|
@@ -24,6 +34,26 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
|
24
34
|
return proto === Object.prototype || proto === null;
|
|
25
35
|
}
|
|
26
36
|
|
|
37
|
+
/**
|
|
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
|
|
42
|
+
* that cycles through it find it.
|
|
43
|
+
*/
|
|
44
|
+
function builtinLike<C extends object>(
|
|
45
|
+
obj: object,
|
|
46
|
+
empty: C,
|
|
47
|
+
made: (copy: C) => void,
|
|
48
|
+
value: (key: string) => unknown,
|
|
49
|
+
): C {
|
|
50
|
+
made(empty);
|
|
51
|
+
if (registeredClassName(obj) === undefined) return empty;
|
|
52
|
+
Object.setPrototypeOf(empty, Object.getPrototypeOf(obj) as object);
|
|
53
|
+
for (const key of extraKeys(obj)) setOwn(empty, key, value(key));
|
|
54
|
+
return empty;
|
|
55
|
+
}
|
|
56
|
+
|
|
27
57
|
/** Own keys of an error that are not enumerable but are data. */
|
|
28
58
|
const ERROR_HIDDEN_KEYS = ['message', 'cause', 'errors'] as const;
|
|
29
59
|
|
|
@@ -83,11 +113,17 @@ export function deepClone<T>(value: T, options: DeepCloneOptions = {}): T {
|
|
|
83
113
|
}
|
|
84
114
|
|
|
85
115
|
function cloneBuiltin(val: object): object | undefined {
|
|
86
|
-
|
|
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
|
+
}
|
|
87
120
|
if (val instanceof RegExp) {
|
|
88
|
-
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
|
+
);
|
|
89
125
|
copy.lastIndex = val.lastIndex; // the scanning cursor of g/y patterns
|
|
90
|
-
return
|
|
126
|
+
return builtin(val, copy);
|
|
91
127
|
}
|
|
92
128
|
if (val instanceof ArrayBuffer) return keep(val, val.slice(0));
|
|
93
129
|
if (ArrayBuffer.isView(val)) {
|
|
@@ -131,6 +167,15 @@ export function deepClone<T>(value: T, options: DeepCloneOptions = {}): T {
|
|
|
131
167
|
: copy;
|
|
132
168
|
}
|
|
133
169
|
|
|
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(
|
|
173
|
+
obj,
|
|
174
|
+
empty,
|
|
175
|
+
(copy) => keep(obj, copy),
|
|
176
|
+
(key) => clone((obj as Record<string, unknown>)[key]),
|
|
177
|
+
);
|
|
178
|
+
|
|
134
179
|
function clone(val: unknown): unknown {
|
|
135
180
|
if (val === null || typeof val !== 'object') return val;
|
|
136
181
|
|
|
@@ -139,27 +184,33 @@ export function deepClone<T>(value: T, options: DeepCloneOptions = {}): T {
|
|
|
139
184
|
|
|
140
185
|
if (Array.isArray(val)) {
|
|
141
186
|
// Holes stay holes
|
|
142
|
-
const arr =
|
|
187
|
+
const arr = builtin(obj, new Array(val.length) as unknown[]);
|
|
143
188
|
for (let i = 0; i < val.length; i++) {
|
|
144
189
|
if (i in val) arr[i] = clone(val[i]);
|
|
145
190
|
}
|
|
146
191
|
return arr;
|
|
147
192
|
}
|
|
148
193
|
|
|
194
|
+
// Read and filled with the built-in methods: a subclass may override
|
|
195
|
+
// them (see mapEntries)
|
|
149
196
|
if (val instanceof Map) {
|
|
150
|
-
const copy =
|
|
151
|
-
for (const [k, v] of val)
|
|
197
|
+
const copy = builtin(obj, new Map());
|
|
198
|
+
for (const [k, v] of mapEntries(val)) {
|
|
199
|
+
Map.prototype.set.call(copy, settled(clone(k), k), clone(v));
|
|
200
|
+
}
|
|
152
201
|
return copy;
|
|
153
202
|
}
|
|
154
203
|
|
|
155
204
|
if (val instanceof Set) {
|
|
156
|
-
const copy =
|
|
157
|
-
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
|
+
}
|
|
158
209
|
return copy;
|
|
159
210
|
}
|
|
160
211
|
|
|
161
|
-
const
|
|
162
|
-
if (
|
|
212
|
+
const copied = cloneBuiltin(obj);
|
|
213
|
+
if (copied !== undefined) return copied;
|
|
163
214
|
|
|
164
215
|
// A registered class instance keeps its class, and a plain object its
|
|
165
216
|
// prototype (which may be null). An instance of an unregistered class
|
|
@@ -295,15 +346,23 @@ export function shareEqual<T>(prev: unknown, curr: T): T {
|
|
|
295
346
|
if (placed !== undefined) return placed;
|
|
296
347
|
if (!isObjectValue(p)) return fresh(c);
|
|
297
348
|
if (reuse(p, c)) return p;
|
|
349
|
+
const pr = p as Record<string, unknown>;
|
|
350
|
+
const cr = c as Record<string, unknown>;
|
|
351
|
+
/** What the result holds at `key` of `c`. */
|
|
352
|
+
const shareKey = (key: string) =>
|
|
353
|
+
share(hasOwn(pr, key) ? pr[key] : undefined, cr[key]);
|
|
354
|
+
/** `empty`, made the result for the collection `c` (see builtinLike). */
|
|
355
|
+
const collection = <C extends object>(empty: C): C =>
|
|
356
|
+
builtinLike(c, empty, (out) => done.set(c, out), shareKey);
|
|
298
357
|
if (c instanceof Map && p instanceof Map) {
|
|
299
|
-
const out = new Map();
|
|
300
|
-
|
|
301
|
-
|
|
358
|
+
const out = collection(new Map());
|
|
359
|
+
for (const [k, v] of mapEntries(c)) {
|
|
360
|
+
Map.prototype.set.call(out, fresh(k), share(mapGet(p, k), v));
|
|
361
|
+
}
|
|
302
362
|
return out;
|
|
303
363
|
}
|
|
304
364
|
if (Array.isArray(c) && Array.isArray(p)) {
|
|
305
|
-
const out = new Array(c.length) as unknown[];
|
|
306
|
-
done.set(c, out);
|
|
365
|
+
const out = collection(new Array(c.length) as unknown[]);
|
|
307
366
|
for (let i = 0; i < c.length; i++) {
|
|
308
367
|
if (i in c) out[i] = share(p[i], c[i]);
|
|
309
368
|
}
|
|
@@ -312,10 +371,7 @@ export function shareEqual<T>(prev: unknown, curr: T): T {
|
|
|
312
371
|
if (mergesWith(p, c, false)) {
|
|
313
372
|
const out = Object.create(Object.getPrototypeOf(c) as object | null);
|
|
314
373
|
done.set(c, out);
|
|
315
|
-
const
|
|
316
|
-
for (const key of Object.keys(cr)) {
|
|
317
|
-
setOwn(out, key, share(hasOwn(p, key) ? p[key] : undefined, cr[key]));
|
|
318
|
-
}
|
|
374
|
+
for (const key of Object.keys(cr)) setOwn(out, key, shareKey(key));
|
|
319
375
|
return out;
|
|
320
376
|
}
|
|
321
377
|
return fresh(c);
|
|
@@ -344,9 +400,20 @@ function equalBuiltin(
|
|
|
344
400
|
b: object,
|
|
345
401
|
assumed: Pairs,
|
|
346
402
|
): boolean | undefined {
|
|
347
|
-
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
|
+
}
|
|
348
410
|
if (a instanceof RegExp) {
|
|
349
|
-
|
|
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
|
+
);
|
|
350
417
|
}
|
|
351
418
|
if (ArrayBuffer.isView(a)) {
|
|
352
419
|
// A view is its place in its backing buffer, and the whole buffer (which
|
|
@@ -382,17 +449,42 @@ function equalBuiltin(
|
|
|
382
449
|
return undefined;
|
|
383
450
|
}
|
|
384
451
|
|
|
385
|
-
function equalKeys(
|
|
452
|
+
function equalKeys(
|
|
453
|
+
a: object,
|
|
454
|
+
b: object,
|
|
455
|
+
assumed: Pairs,
|
|
456
|
+
keysOf: (o: object) => string[] = Object.keys,
|
|
457
|
+
): boolean {
|
|
386
458
|
const ao = a as Record<string, unknown>;
|
|
387
459
|
const bo = b as Record<string, unknown>;
|
|
388
|
-
const keys =
|
|
389
|
-
if (keys.length !==
|
|
460
|
+
const keys = keysOf(ao);
|
|
461
|
+
if (keys.length !== keysOf(bo).length) return false;
|
|
390
462
|
for (const key of keys) {
|
|
391
463
|
if (!hasOwn(bo, key) || !equal(ao[key], bo[key], assumed)) return false;
|
|
392
464
|
}
|
|
393
465
|
return true;
|
|
394
466
|
}
|
|
395
467
|
|
|
468
|
+
/** Built-in prototypes (see builtinLike), whose instances hold no extra keys. */
|
|
469
|
+
const BUILTIN_PROTOS: ReadonlySet<object | null> = new Set([
|
|
470
|
+
Array.prototype,
|
|
471
|
+
Map.prototype,
|
|
472
|
+
Set.prototype,
|
|
473
|
+
Date.prototype,
|
|
474
|
+
RegExp.prototype,
|
|
475
|
+
]);
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Equality of the extra keys (see extraKeys) of two built-ins of one
|
|
479
|
+
* class: those of a subclass instance are part of its value.
|
|
480
|
+
*/
|
|
481
|
+
function equalExtraKeys(a: object, b: object, assumed: Pairs): boolean {
|
|
482
|
+
return (
|
|
483
|
+
BUILTIN_PROTOS.has(Object.getPrototypeOf(a) as object | null) ||
|
|
484
|
+
equalKeys(a, b, assumed, extraKeys)
|
|
485
|
+
);
|
|
486
|
+
}
|
|
487
|
+
|
|
396
488
|
/** `assumed` tracks the pairs of objects met (see Pairs). */
|
|
397
489
|
function equal(a: unknown, b: unknown, assumed: Pairs): boolean {
|
|
398
490
|
if (Object.is(a, b) && !(assumed.strict && isObjectValue(a))) return true;
|
|
@@ -410,9 +502,9 @@ function equal(a: unknown, b: unknown, assumed: Pairs): boolean {
|
|
|
410
502
|
|
|
411
503
|
if (a instanceof Map || a instanceof Set) {
|
|
412
504
|
const bc = b as Map<unknown, unknown> | Set<unknown>;
|
|
413
|
-
if (a
|
|
414
|
-
const ai = a
|
|
415
|
-
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);
|
|
416
508
|
for (
|
|
417
509
|
let x = ai.next(), y = bi.next();
|
|
418
510
|
!x.done;
|
|
@@ -420,7 +512,7 @@ function equal(a: unknown, b: unknown, assumed: Pairs): boolean {
|
|
|
420
512
|
) {
|
|
421
513
|
if (!equal(x.value, y.value, assumed)) return false;
|
|
422
514
|
}
|
|
423
|
-
return
|
|
515
|
+
return equalExtraKeys(a, b, assumed);
|
|
424
516
|
}
|
|
425
517
|
|
|
426
518
|
const builtin = equalBuiltin(a, b, assumed);
|
|
@@ -432,7 +524,7 @@ function equal(a: unknown, b: unknown, assumed: Pairs): boolean {
|
|
|
432
524
|
for (let i = 0; i < a.length; i++) {
|
|
433
525
|
if (i in a !== i in bc || !equal(a[i], bc[i], assumed)) return false;
|
|
434
526
|
}
|
|
435
|
-
return
|
|
527
|
+
return equalExtraKeys(a, b, assumed);
|
|
436
528
|
}
|
|
437
529
|
|
|
438
530
|
return equalKeys(a, b, assumed);
|
|
@@ -466,7 +558,11 @@ export function mergesWith(
|
|
|
466
558
|
arrays: boolean,
|
|
467
559
|
): a is Record<string, unknown> {
|
|
468
560
|
if (arrays && (Array.isArray(a) || Array.isArray(b))) {
|
|
469
|
-
return
|
|
561
|
+
return (
|
|
562
|
+
Array.isArray(a) &&
|
|
563
|
+
Array.isArray(b) &&
|
|
564
|
+
Object.getPrototypeOf(a) === Object.getPrototypeOf(b)
|
|
565
|
+
);
|
|
470
566
|
}
|
|
471
567
|
return (
|
|
472
568
|
isMergeable(a) &&
|
|
@@ -698,13 +794,15 @@ function entryChildren(value: object): [string, unknown][] | undefined {
|
|
|
698
794
|
const children: [string, unknown][] = [];
|
|
699
795
|
if (value instanceof Map) {
|
|
700
796
|
let index = 0;
|
|
701
|
-
for (const [k, v] of value) {
|
|
797
|
+
for (const [k, v] of mapEntries(value)) {
|
|
702
798
|
children.push([entryKey('k', index), k], [entryKey('v', index), v]);
|
|
703
799
|
index++;
|
|
704
800
|
}
|
|
705
801
|
} else if (value instanceof Set) {
|
|
706
802
|
let index = 0;
|
|
707
|
-
for (const member of value)
|
|
803
|
+
for (const member of setMembers(value)) {
|
|
804
|
+
children.push([entryKey('s', index++), member]);
|
|
805
|
+
}
|
|
708
806
|
} else if (value instanceof Error) {
|
|
709
807
|
for (const key of ['cause', 'errors'] as const) {
|
|
710
808
|
if (hasOwn(value, key)) {
|
|
@@ -755,8 +853,9 @@ function setEntryValue(
|
|
|
755
853
|
const holder = getByEntryPath(root, path.slice(0, -1));
|
|
756
854
|
const match = /^\0v(\d+)$/.exec(path[path.length - 1]!);
|
|
757
855
|
if (!(holder instanceof Map) || !match) return false;
|
|
758
|
-
const
|
|
759
|
-
|
|
856
|
+
const entry = [...mapEntries(holder)][Number(match[1])];
|
|
857
|
+
if (!entry) return false;
|
|
858
|
+
mapSet(holder, entry[0], value);
|
|
760
859
|
return true;
|
|
761
860
|
}
|
|
762
861
|
|
|
@@ -857,7 +956,8 @@ function aliasMoved(
|
|
|
857
956
|
if (xs && ys) {
|
|
858
957
|
if (Object.getPrototypeOf(x) !== Object.getPrototypeOf(y)) return false;
|
|
859
958
|
if (x instanceof Map || x instanceof Set) {
|
|
860
|
-
|
|
959
|
+
const ySize = collectionSize(y as Map<unknown, unknown>);
|
|
960
|
+
if (collectionSize(x) !== ySize) return false;
|
|
861
961
|
}
|
|
862
962
|
const held = new Map(ys);
|
|
863
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
|
|
|
@@ -43,3 +45,57 @@ export function atomicName(value: object): string | undefined {
|
|
|
43
45
|
const tag = value instanceof Error ? 'Error' : toStringTag(value);
|
|
44
46
|
return `${/^[AEIOU]/.test(tag) ? 'an' : 'a'} ${tag}`;
|
|
45
47
|
}
|
|
48
|
+
|
|
49
|
+
/** Whether `key` is an index of an array element. */
|
|
50
|
+
const isIndexKey = (key: string): boolean =>
|
|
51
|
+
key !== '4294967295' && String(Number(key) >>> 0) === key;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The own enumerable string keys of a collection (an Array, Map or Set) that
|
|
55
|
+
* are not its elements: what an instance of a subclass may hold besides
|
|
56
|
+
* them.
|
|
57
|
+
*/
|
|
58
|
+
export function extraKeys(collection: object): string[] {
|
|
59
|
+
const keys = Object.keys(collection);
|
|
60
|
+
return Array.isArray(collection) ? keys.filter((k) => !isIndexKey(k)) : keys;
|
|
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
|
+
}
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { parseMacroArgs } from '../components/macros/macro-args';
|
|
6
6
|
import type { MacroArgs } from '../registry';
|
|
7
|
+
import { tokenizeMarkupTolerant } from '../markup/parse';
|
|
7
8
|
|
|
8
9
|
/** A {widget} definition's name, then its `@` parameters. */
|
|
9
10
|
export const WIDGET_PARAMETERS = [
|
|
@@ -36,20 +37,15 @@ export function parseWidgetDef(rawArgs: string): WidgetDef {
|
|
|
36
37
|
return widgetDef(parseMacroArgs(rawArgs, WIDGET_PARAMETERS));
|
|
37
38
|
}
|
|
38
39
|
|
|
39
|
-
/**
|
|
40
|
-
* A definition: its arguments (read like a {widget} macro's, see
|
|
41
|
-
* parseWidgetDef) and its body. Macro names are not case-sensitive.
|
|
42
|
-
*/
|
|
43
|
-
const WIDGET_DEFINITION = /\{widget\s+([^}]*)\}([\s\S]*?)\{\/widget\}/gi;
|
|
44
|
-
|
|
45
|
-
/** `{@children}`, also with selectors such as `{.highlight @children}`. */
|
|
46
|
-
const CHILDREN_PLACEHOLDER = /\{[^{}]*@children\s*\}/;
|
|
47
|
-
|
|
48
40
|
/**
|
|
49
41
|
* The names of the block widgets (those whose body renders `{@children}`)
|
|
50
42
|
* that StoryInit and the passages tagged `widget` define. They must be
|
|
51
43
|
* known as block macros before any passage is parsed, so that passages
|
|
52
44
|
* invoking them nest their content whatever the passage order.
|
|
45
|
+
*
|
|
46
|
+
* The passages are read as flat tokens, which need no block macros to be
|
|
47
|
+
* known, so a `{@children}` in an HTML comment or in a `{do}` body is text
|
|
48
|
+
* here as it is to the parser, and does not make a block widget (#387).
|
|
53
49
|
*/
|
|
54
50
|
export function blockWidgetNames(
|
|
55
51
|
passages: Iterable<{ name: string; tags?: string[]; content: string }>,
|
|
@@ -59,12 +55,28 @@ export function blockWidgetNames(
|
|
|
59
55
|
if (passage.name !== 'StoryInit' && !passage.tags?.includes('widget')) {
|
|
60
56
|
continue;
|
|
61
57
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
58
|
+
// The open definitions: their arguments, and whether a slot was seen
|
|
59
|
+
const open: { rawArgs: string; isBlock: boolean }[] = [];
|
|
60
|
+
for (const token of tokenizeMarkupTolerant(passage.content).tokens) {
|
|
61
|
+
if (token.type === 'macro' && token.name.toLowerCase() === 'widget') {
|
|
62
|
+
if (!token.isClose) {
|
|
63
|
+
open.push({ rawArgs: token.rawArgs, isBlock: false });
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
const def = open.pop();
|
|
67
|
+
if (!def?.isBlock) continue;
|
|
68
|
+
try {
|
|
69
|
+
names.push(parseWidgetDef(def.rawArgs).name);
|
|
70
|
+
} catch {
|
|
71
|
+
// The {widget} macro reports a definition it cannot read
|
|
72
|
+
}
|
|
73
|
+
} else if (
|
|
74
|
+
token.type === 'variable' &&
|
|
75
|
+
token.scope === 'local' &&
|
|
76
|
+
token.name === 'children' &&
|
|
77
|
+
open.length > 0
|
|
78
|
+
) {
|
|
79
|
+
open[open.length - 1]!.isBlock = true;
|
|
68
80
|
}
|
|
69
81
|
}
|
|
70
82
|
}
|
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
|
|