@rohal12/spindle 0.59.14 → 0.59.16
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 +1 -0
- package/dist/pkg/format.js +1 -1
- package/dist/pkg/headless.js +814 -321
- package/dist/pkg/story-variables.js +349 -152
- 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/For.tsx +16 -9
- package/src/components/macros/Radiobutton.tsx +4 -1
- package/src/components/macros/Watch.tsx +24 -4
- package/src/components/macros/detached-body.tsx +17 -6
- package/src/define-macro.ts +11 -2
- package/src/execute-mutation.ts +3 -2
- package/src/markup/render.tsx +17 -1
- package/src/markup/spindle.peggy +16 -0
- package/src/runtime-errors.ts +4 -8
- package/src/saves/save-manager.ts +63 -0
- package/src/store.ts +54 -10
- package/src/story-api.ts +4 -3
- package/src/story-variables.ts +15 -3
- package/src/structural.ts +74 -38
- package/src/triggers.ts +23 -2
- package/src/utils/control-edits.ts +81 -30
- package/src/utils/listeners.ts +23 -0
- package/src/utils/object-path.ts +18 -6
- package/src/utils/stable-key.ts +73 -40
- package/src/utils/value-kinds.ts +42 -0
- package/types/index.d.ts +7 -2
- package/types/tooling.d.ts +5 -1
package/package.json
CHANGED
package/src/class-registry.ts
CHANGED
|
@@ -2,14 +2,48 @@
|
|
|
2
2
|
|
|
3
3
|
import { parse, stringify } from 'devalue';
|
|
4
4
|
import { hasOwn } from './utils/namespace';
|
|
5
|
-
import { extraKeys } from './utils/value-kinds';
|
|
5
|
+
import { extraKeys, mapEntries, setMembers } from './utils/value-kinds';
|
|
6
6
|
|
|
7
7
|
type Constructor = new (...args: any[]) => any;
|
|
8
8
|
|
|
9
9
|
const registry = new Map<string, Constructor>();
|
|
10
10
|
const ctorToName = new Map<Constructor, string>();
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Built-ins whose instances hold their value in internal slots that a save
|
|
14
|
+
* cannot rebuild: a registered class cannot extend them. (Array, Map, Set,
|
|
15
|
+
* Date, RegExp and Error can be extended, see classData.)
|
|
16
|
+
*/
|
|
17
|
+
const UNSUPPORTED_BASES: readonly Constructor[] = [
|
|
18
|
+
ArrayBuffer,
|
|
19
|
+
DataView,
|
|
20
|
+
Object.getPrototypeOf(Int8Array) as Constructor, // every typed array
|
|
21
|
+
URL,
|
|
22
|
+
URLSearchParams,
|
|
23
|
+
Number,
|
|
24
|
+
String,
|
|
25
|
+
Boolean,
|
|
26
|
+
Promise,
|
|
27
|
+
WeakMap,
|
|
28
|
+
WeakSet,
|
|
29
|
+
Function,
|
|
30
|
+
] as Constructor[];
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Register `ctor` as `name`, so that its instances keep their class through
|
|
34
|
+
* copies, history and saves. Throws for a class extending a built-in whose
|
|
35
|
+
* instances cannot be saved (see UNSUPPORTED_BASES).
|
|
36
|
+
*/
|
|
12
37
|
export function registerClass(name: string, ctor: Constructor): void {
|
|
38
|
+
const base =
|
|
39
|
+
typeof ctor === 'function'
|
|
40
|
+
? UNSUPPORTED_BASES.find((b) => ctor.prototype instanceof b)
|
|
41
|
+
: undefined;
|
|
42
|
+
if (base) {
|
|
43
|
+
throw new TypeError(
|
|
44
|
+
`spindle: Cannot register class "${name}": it extends ${base.name}, whose instances cannot be saved (a registered class may extend Array, Map, Set, Date, RegExp or Error)`,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
13
47
|
registry.set(name, ctor);
|
|
14
48
|
ctorToName.set(ctor, name);
|
|
15
49
|
}
|
|
@@ -48,8 +82,9 @@ export function clearRegistry(): void {
|
|
|
48
82
|
//
|
|
49
83
|
// - `c:<name>`: an instance of the class registered as <name>, with its own
|
|
50
84
|
// enumerable keys (and, for Error subclasses, its message and cause); for
|
|
51
|
-
// a subclass of Array, Map or
|
|
52
|
-
//
|
|
85
|
+
// a subclass of Array, Map, Set, Date or RegExp, `[kind, contents, keys]`:
|
|
86
|
+
// the built-in it extends, what that holds (elements, Map entries as
|
|
87
|
+
// pairs, the time, or the source, flags and lastIndex) and its other own
|
|
53
88
|
// enumerable keys;
|
|
54
89
|
// - `E:<name>`: a built-in error (Error, TypeError, ..., AggregateError),
|
|
55
90
|
// with its message, cause, errors and own enumerable keys (not its stack);
|
|
@@ -155,18 +190,24 @@ function ownData(
|
|
|
155
190
|
return data;
|
|
156
191
|
}
|
|
157
192
|
|
|
158
|
-
/**
|
|
159
|
-
|
|
193
|
+
/**
|
|
194
|
+
* The built-ins a registered class may extend whose value is not in its
|
|
195
|
+
* keys, saved as their kind (see classData).
|
|
196
|
+
*/
|
|
197
|
+
const BUILTIN_KINDS = {
|
|
160
198
|
Array: Array as unknown as Constructor,
|
|
161
199
|
Map: Map as Constructor,
|
|
162
200
|
Set: Set as Constructor,
|
|
201
|
+
Date: Date as Constructor,
|
|
202
|
+
RegExp: RegExp as Constructor,
|
|
163
203
|
};
|
|
164
|
-
type
|
|
204
|
+
type BuiltinKind = keyof typeof BUILTIN_KINDS;
|
|
165
205
|
|
|
166
206
|
/**
|
|
167
207
|
* The saved form of a registered class instance: its own data (see
|
|
168
|
-
* ownData), or for a
|
|
169
|
-
* with the built-in methods, which a
|
|
208
|
+
* ownData), or for a subclass of a built-in kind (see BUILTIN_KINDS) its
|
|
209
|
+
* kind, contents and extra keys, read with the built-in methods, which a
|
|
210
|
+
* subclass may override (#391, #393).
|
|
170
211
|
*/
|
|
171
212
|
function classData(value: object): unknown {
|
|
172
213
|
const extra = () => ownData(value, extraKeys(value));
|
|
@@ -179,10 +220,18 @@ function classData(value: object): unknown {
|
|
|
179
220
|
return ['Array', items, extra()];
|
|
180
221
|
}
|
|
181
222
|
if (value instanceof Map) {
|
|
182
|
-
return ['Map', Array.from(
|
|
223
|
+
return ['Map', Array.from(mapEntries(value)), extra()];
|
|
183
224
|
}
|
|
184
225
|
if (value instanceof Set) {
|
|
185
|
-
return ['Set', Array.from(
|
|
226
|
+
return ['Set', Array.from(setMembers(value)), extra()];
|
|
227
|
+
}
|
|
228
|
+
if (value instanceof Date) {
|
|
229
|
+
return ['Date', Date.prototype.getTime.call(value), extra()];
|
|
230
|
+
}
|
|
231
|
+
if (value instanceof RegExp) {
|
|
232
|
+
const source = Reflect.get(RegExp.prototype, 'source', value) as string;
|
|
233
|
+
const flags = Reflect.get(RegExp.prototype, 'flags', value) as string;
|
|
234
|
+
return ['RegExp', [source, flags, value.lastIndex], extra()];
|
|
186
235
|
}
|
|
187
236
|
return ownData(value);
|
|
188
237
|
}
|
|
@@ -241,33 +290,64 @@ function reducers(): Record<string, (value: unknown) => unknown> {
|
|
|
241
290
|
}
|
|
242
291
|
|
|
243
292
|
/**
|
|
244
|
-
* The
|
|
245
|
-
*
|
|
246
|
-
|
|
247
|
-
|
|
293
|
+
* The RegExp of a `[source, flags, lastIndex]` list (an `R` entry, or the
|
|
294
|
+
* contents of a RegExp subclass), or undefined if the list is malformed.
|
|
295
|
+
*/
|
|
296
|
+
function newRegExp(data: unknown): RegExp | undefined {
|
|
297
|
+
if (
|
|
298
|
+
!Array.isArray(data) ||
|
|
299
|
+
typeof data[0] !== 'string' ||
|
|
300
|
+
typeof data[1] !== 'string' ||
|
|
301
|
+
!Number.isSafeInteger(data[2])
|
|
302
|
+
) {
|
|
303
|
+
return undefined;
|
|
304
|
+
}
|
|
305
|
+
const re = new RegExp(data[0], data[1]);
|
|
306
|
+
re.lastIndex = data[2] as number;
|
|
307
|
+
return re;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* A new Date or RegExp holding `contents` (see classData), or undefined if
|
|
312
|
+
* they are malformed.
|
|
313
|
+
*/
|
|
314
|
+
function newAtomic(kind: 'Date' | 'RegExp', contents: unknown) {
|
|
315
|
+
if (kind === 'RegExp') return newRegExp(contents);
|
|
316
|
+
return typeof contents === 'number' ? new Date(contents) : undefined;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* The built-in of a registered class that a `[kind, contents, keys]` list
|
|
321
|
+
* holds (see classData). A cycle through a collection revives it before its
|
|
322
|
+
* list is complete (devalue holds unread parts as holes), so the instance
|
|
323
|
+
* is kept per list and filled with what the list holds so far. (The
|
|
324
|
+
* contents of a Date or RegExp hold no objects, so they are complete.)
|
|
248
325
|
*/
|
|
249
326
|
const collections = new WeakMap<unknown[], object>();
|
|
250
|
-
function
|
|
327
|
+
function reviveBuiltin(name: string, ctor: Constructor, list: unknown[]) {
|
|
251
328
|
const [kind, contents, keys] = list;
|
|
252
329
|
const base =
|
|
253
|
-
typeof kind === 'string' && hasOwn(
|
|
254
|
-
?
|
|
330
|
+
typeof kind === 'string' && hasOwn(BUILTIN_KINDS, kind)
|
|
331
|
+
? BUILTIN_KINDS[kind as BuiltinKind]
|
|
255
332
|
: undefined;
|
|
333
|
+
const atomic = kind === 'Date' || kind === 'RegExp';
|
|
256
334
|
if (
|
|
257
335
|
list.length !== 3 ||
|
|
258
336
|
!base ||
|
|
259
337
|
!(ctor.prototype instanceof base) ||
|
|
260
|
-
(hasOwn(list, 1) && !Array.isArray(contents)) ||
|
|
338
|
+
(!atomic && hasOwn(list, 1) && !Array.isArray(contents)) ||
|
|
261
339
|
(hasOwn(list, 2) && !isPlainData(keys))
|
|
262
340
|
) {
|
|
263
341
|
throw malformedClass(name);
|
|
264
342
|
}
|
|
265
343
|
let made = collections.get(list);
|
|
266
344
|
if (!made) {
|
|
267
|
-
|
|
345
|
+
const value = atomic ? newAtomic(kind, contents) : (new base() as object);
|
|
346
|
+
if (!value) throw malformedClass(name);
|
|
347
|
+
made = Object.setPrototypeOf(value, ctor.prototype) as object;
|
|
268
348
|
collections.set(list, made);
|
|
269
349
|
}
|
|
270
|
-
if (Array.isArray(contents)) fillCollection(made, contents);
|
|
350
|
+
if (!atomic && Array.isArray(contents)) fillCollection(made, contents);
|
|
271
351
|
if (isPlainData(keys)) {
|
|
272
352
|
for (const key of Object.keys(keys)) defineData(made, key, keys[key]);
|
|
273
353
|
}
|
|
@@ -300,7 +380,7 @@ function fillCollection(made: object, contents: unknown[]): void {
|
|
|
300
380
|
function reviveClass(name: string) {
|
|
301
381
|
return (data: Record<string, unknown> | unknown[]): unknown => {
|
|
302
382
|
if (Array.isArray(data)) {
|
|
303
|
-
return
|
|
383
|
+
return reviveBuiltin(name, registeredCtor(name), data);
|
|
304
384
|
}
|
|
305
385
|
if (!isPlainData(data)) {
|
|
306
386
|
// Already revived: a cycle through the instance revives it twice
|
|
@@ -308,10 +388,8 @@ function reviveClass(name: string) {
|
|
|
308
388
|
throw malformedClass(name);
|
|
309
389
|
}
|
|
310
390
|
const ctor = registeredCtor(name);
|
|
311
|
-
// A plain object cannot be made a
|
|
312
|
-
if (
|
|
313
|
-
Object.values(COLLECTION_KINDS).some((c) => ctor.prototype instanceof c)
|
|
314
|
-
) {
|
|
391
|
+
// A plain object cannot be made a built-in (see classData)
|
|
392
|
+
if (Object.values(BUILTIN_KINDS).some((c) => ctor.prototype instanceof c)) {
|
|
315
393
|
throw malformedClass(name);
|
|
316
394
|
}
|
|
317
395
|
Object.setPrototypeOf(data, ctor.prototype as object);
|
|
@@ -391,16 +469,8 @@ function reviverFor(key: string | symbol) {
|
|
|
391
469
|
if (key === KEYS_TAG) return reviveKeyed;
|
|
392
470
|
if (key === REGEXP_TAG) {
|
|
393
471
|
return (data: unknown) => {
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
typeof data[0] !== 'string' ||
|
|
397
|
-
typeof data[1] !== 'string' ||
|
|
398
|
-
!Number.isSafeInteger(data[2])
|
|
399
|
-
) {
|
|
400
|
-
throw new TypeError('spindle: Malformed regular expression');
|
|
401
|
-
}
|
|
402
|
-
const re = new RegExp(data[0], data[1]);
|
|
403
|
-
re.lastIndex = data[2] as number;
|
|
472
|
+
const re = newRegExp(data);
|
|
473
|
+
if (!re) throw new TypeError('spindle: Malformed regular expression');
|
|
404
474
|
return re;
|
|
405
475
|
};
|
|
406
476
|
}
|
|
@@ -7,7 +7,12 @@ import {
|
|
|
7
7
|
} from 'preact/hooks';
|
|
8
8
|
import { parseMarkup } from '../markup/parse';
|
|
9
9
|
import type { ASTNode } from '../markup/ast';
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
renderNodes,
|
|
12
|
+
NobrContext,
|
|
13
|
+
ViewScopeContext,
|
|
14
|
+
useViewScope,
|
|
15
|
+
} from '../markup/render';
|
|
11
16
|
import { useStoryFields } from '../hooks/use-story-fields';
|
|
12
17
|
import type { Passage as PassageData } from '../parser';
|
|
13
18
|
import { sourceLocationOf } from '../utils/source-location';
|
|
@@ -130,20 +135,23 @@ export function Passage({
|
|
|
130
135
|
}, [passage.name, navigationId]);
|
|
131
136
|
|
|
132
137
|
const nobr = passage.tags.includes('nobr');
|
|
138
|
+
const viewScope = useViewScope();
|
|
133
139
|
|
|
134
140
|
const inner = (
|
|
135
|
-
<
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
141
|
+
<ViewScopeContext.Provider value={viewScope}>
|
|
142
|
+
<div
|
|
143
|
+
ref={elRef}
|
|
144
|
+
class="passage"
|
|
145
|
+
data-passage={passage.name}
|
|
146
|
+
data-tags={passage.tags.join(' ')}
|
|
147
|
+
data-transition={dataTransition}
|
|
148
|
+
>
|
|
149
|
+
{headerContent && <div class="passage-header">{headerContent}</div>}
|
|
150
|
+
{content}
|
|
151
|
+
{footerContent && <div class="passage-footer">{footerContent}</div>}
|
|
152
|
+
{doneContent && <div hidden>{doneContent}</div>}
|
|
153
|
+
</div>
|
|
154
|
+
</ViewScopeContext.Provider>
|
|
147
155
|
);
|
|
148
156
|
|
|
149
157
|
return nobr ? (
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import { createContext } from 'preact';
|
|
2
2
|
import { useCallback, useLayoutEffect, useMemo, useRef } from 'preact/hooks';
|
|
3
3
|
import { parseMarkup } from '../markup/parse';
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
renderNodes,
|
|
6
|
+
NobrContext,
|
|
7
|
+
ViewScopeContext,
|
|
8
|
+
useViewScope,
|
|
9
|
+
} from '../markup/render';
|
|
5
10
|
import { useStoryFields } from '../hooks/use-story-fields';
|
|
6
11
|
import { emitFromRender } from '../event-emitter';
|
|
7
12
|
import { useModalFocus } from '../hooks/use-modal-focus';
|
|
@@ -66,6 +71,7 @@ export function PassageDialog({
|
|
|
66
71
|
}, [markup, nobr]);
|
|
67
72
|
|
|
68
73
|
const panelRef = useRef<HTMLDivElement>(null);
|
|
74
|
+
const viewScope = useViewScope();
|
|
69
75
|
|
|
70
76
|
// Focus into the dialog, trap Tab, Escape to close, restore focus on close.
|
|
71
77
|
// Declared before the dialogrender effect so handlers can move focus.
|
|
@@ -113,7 +119,11 @@ export function PassageDialog({
|
|
|
113
119
|
✕
|
|
114
120
|
</button>
|
|
115
121
|
)}
|
|
116
|
-
<div class="dialog-body">
|
|
122
|
+
<div class="dialog-body">
|
|
123
|
+
<ViewScopeContext.Provider value={viewScope}>
|
|
124
|
+
{content}
|
|
125
|
+
</ViewScopeContext.Provider>
|
|
126
|
+
</div>
|
|
117
127
|
</div>
|
|
118
128
|
</div>
|
|
119
129
|
</DialogCloseContext.Provider>
|
|
@@ -3,7 +3,11 @@ import { LocalsValuesContext } from '../../markup/render';
|
|
|
3
3
|
import { defineMacro } from '../../define-macro';
|
|
4
4
|
import { MacroError } from './MacroError';
|
|
5
5
|
import { stableKey } from '../../utils/stable-key';
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
ItemEditContext,
|
|
8
|
+
controlEditVersion,
|
|
9
|
+
editedItems,
|
|
10
|
+
} from '../../utils/control-edits';
|
|
7
11
|
import type { ASTNode } from '../../markup/ast';
|
|
8
12
|
import { checkVariableName } from '../../utils/namespace';
|
|
9
13
|
import { readState } from '../../execute-mutation';
|
|
@@ -129,18 +133,17 @@ defineMacro({
|
|
|
129
133
|
|
|
130
134
|
const { itemVar, indexVar } = loop;
|
|
131
135
|
// An iteration is remounted when its item's contents change, so mount-only
|
|
132
|
-
// macros run again for the new item (#45) -- unless the change is
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
|
|
136
|
-
const
|
|
136
|
+
// macros run again for the new item (#45) -- unless the change is an edit
|
|
137
|
+
// of the item (see control-edits): a reader typing into a control, which
|
|
138
|
+
// a remount would take the focus from, or code the iterations ran, which
|
|
139
|
+
// a remount would run again (#400).
|
|
140
|
+
const edited = editedItems(keys.edits, readState());
|
|
137
141
|
keys.edits = controlEditVersion();
|
|
138
142
|
const generations = list.map((item, i) => {
|
|
139
143
|
const contents = stableKey(item);
|
|
140
144
|
if (
|
|
141
145
|
keys.generations[i] === undefined ||
|
|
142
|
-
(contents !== keys.contents[i] &&
|
|
143
|
-
!editedItem(since, variables, list, item, i))
|
|
146
|
+
(contents !== keys.contents[i] && !edited(list, item, i))
|
|
144
147
|
) {
|
|
145
148
|
keys.generations[i] = keys.next++;
|
|
146
149
|
}
|
|
@@ -160,7 +163,11 @@ defineMacro({
|
|
|
160
163
|
/>
|
|
161
164
|
));
|
|
162
165
|
|
|
163
|
-
return ctx.wrap(
|
|
166
|
+
return ctx.wrap(
|
|
167
|
+
<ItemEditContext.Provider value={true}>
|
|
168
|
+
{content}
|
|
169
|
+
</ItemEditContext.Provider>,
|
|
170
|
+
);
|
|
164
171
|
},
|
|
165
172
|
text({ rawArgs, children = [] }, ctx) {
|
|
166
173
|
const { itemVar, indexVar, listExpr } = loopArgs(rawArgs, ctx.args);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { defineMacro } from '../../define-macro';
|
|
2
|
+
import { ViewScopeContext } from '../../markup/render';
|
|
2
3
|
import { VARIABLE_PARAMETER, useVariableAction } from './input-macro';
|
|
3
4
|
|
|
4
5
|
defineMacro({
|
|
@@ -12,6 +13,8 @@ defineMacro({
|
|
|
12
13
|
render(_props, ctx) {
|
|
13
14
|
const radioValue = ctx.args.value ?? '';
|
|
14
15
|
const label = ctx.args.label ?? '';
|
|
16
|
+
// One group per variable in each view (see ViewScopeContext)
|
|
17
|
+
const view = ctx.hooks.useContext(ViewScopeContext);
|
|
15
18
|
|
|
16
19
|
useVariableAction(ctx, {
|
|
17
20
|
type: 'radiobutton',
|
|
@@ -27,7 +30,7 @@ defineMacro({
|
|
|
27
30
|
>
|
|
28
31
|
<input
|
|
29
32
|
type="radio"
|
|
30
|
-
name={`radio-${ctx.varName}`}
|
|
33
|
+
name={view ? `radio-${view}-${ctx.varName}` : `radio-${ctx.varName}`}
|
|
31
34
|
checked={ctx.value === radioValue}
|
|
32
35
|
onChange={() => ctx.setValue!(radioValue)}
|
|
33
36
|
/>
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import { defineMacro } from '../../define-macro';
|
|
2
|
-
import {
|
|
2
|
+
import { FrozenStateContext } from '../../hooks/use-story-fields';
|
|
3
|
+
import { useStoryStore } from '../../store';
|
|
4
|
+
import {
|
|
5
|
+
addMacroTrigger,
|
|
6
|
+
removeTrigger,
|
|
7
|
+
subscribeTriggerResets,
|
|
8
|
+
triggerResets,
|
|
9
|
+
} from '../../triggers';
|
|
3
10
|
|
|
4
11
|
defineMacro({
|
|
5
12
|
name: 'watch',
|
|
@@ -34,9 +41,22 @@ defineMacro({
|
|
|
34
41
|
// Register during render (like {set}) — triggers survive navigation
|
|
35
42
|
// and are cleaned up via resetTriggers() on restart or {unwatch}.
|
|
36
43
|
// Remounts (revisits, re-rendered branches) keep the registered watcher.
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
44
|
+
// A {watch} still mounted when a restart forgets the watchers (in the
|
|
45
|
+
// story interface) registers it again for the new game (#402); one in a
|
|
46
|
+
// passage on its way out or a finished click body does not.
|
|
47
|
+
const frozen = hooks.useContext(FrozenStateContext);
|
|
48
|
+
const [, rerender] = hooks.useState(0);
|
|
49
|
+
hooks.useEffect(
|
|
50
|
+
() => subscribeTriggerResets(() => rerender((n) => n + 1)),
|
|
51
|
+
[],
|
|
52
|
+
);
|
|
53
|
+
const registered = hooks.useRef<number | null>(null);
|
|
54
|
+
if (
|
|
55
|
+
registered.current === null ||
|
|
56
|
+
(registered.current !== triggerResets() &&
|
|
57
|
+
!frozen?.(useStoryStore.getState()))
|
|
58
|
+
) {
|
|
59
|
+
registered.current = triggerResets();
|
|
40
60
|
addMacroTrigger(condition, options);
|
|
41
61
|
}
|
|
42
62
|
|
|
@@ -13,6 +13,7 @@ import { useRenderOptions } from '../../hooks/use-render-options';
|
|
|
13
13
|
import { FrozenStateContext } from '../../hooks/use-story-fields';
|
|
14
14
|
import { liveLocalsView } from '../../utils/live-locals';
|
|
15
15
|
import { runWithCommittedMutations } from '../../execute-mutation';
|
|
16
|
+
import { subscribeStateReplacement } from '../../store';
|
|
16
17
|
import { RepeatContext } from './Repeat';
|
|
17
18
|
import { DialogCloseContext } from '../PassageDialog';
|
|
18
19
|
|
|
@@ -26,7 +27,9 @@ const frozenAlways = () => true;
|
|
|
26
27
|
* once it has rendered, so it runs once per click. It stays mounted
|
|
27
28
|
* until the owning macro unmounts (its passage changes, say), so work it
|
|
28
29
|
* starts in an effect, such as the timer of a {timed} or {repeat}, can
|
|
29
|
-
* finish; the passage leaving cancels it
|
|
30
|
+
* finish; the passage leaving cancels it, and so does a restart or a load
|
|
31
|
+
* replacing the game, which a control of the story interface outlives
|
|
32
|
+
* (#401).
|
|
30
33
|
*
|
|
31
34
|
* A detached render starts with no contexts, so the hook captures the ones
|
|
32
35
|
* the owning macro sees and provides them again: the locals scope (read
|
|
@@ -49,14 +52,22 @@ export function useDetachedBody(): (children: ASTNode[]) => void {
|
|
|
49
52
|
const closeDialog = useContext(DialogCloseContext);
|
|
50
53
|
|
|
51
54
|
const mounted = useRef<HTMLElement[]>([]);
|
|
52
|
-
useEffect(
|
|
53
|
-
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
const unsubscribe = subscribeStateReplacement(() => {
|
|
57
|
+
// After the code that replaced the game, which may be running in one
|
|
58
|
+
// of the bodies (`{do}Story.restart(){/do}`), has finished rendering
|
|
59
|
+
const replaced = mounted.current.splice(0);
|
|
60
|
+
queueMicrotask(() => {
|
|
61
|
+
for (const container of replaced) render(null, container);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
return () => {
|
|
65
|
+
unsubscribe();
|
|
54
66
|
for (const container of mounted.current.splice(0)) {
|
|
55
67
|
render(null, container);
|
|
56
68
|
}
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
);
|
|
69
|
+
};
|
|
70
|
+
}, []);
|
|
60
71
|
|
|
61
72
|
const run = (children: ASTNode[]) => {
|
|
62
73
|
const locals = liveLocalsView(updater.getValues);
|
package/src/define-macro.ts
CHANGED
|
@@ -10,7 +10,11 @@ import {
|
|
|
10
10
|
useMemo,
|
|
11
11
|
} from 'preact/hooks';
|
|
12
12
|
import { useInterpolate } from './hooks/use-interpolate';
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
ItemEditContext,
|
|
15
|
+
noteControlEdit,
|
|
16
|
+
runAsItemEdit,
|
|
17
|
+
} from './utils/control-edits';
|
|
14
18
|
import { useMergedLocals } from './hooks/use-merged-locals';
|
|
15
19
|
import { useRenderOptions } from './hooks/use-render-options';
|
|
16
20
|
import {
|
|
@@ -155,6 +159,7 @@ export function defineMacro<const P extends readonly ParameterDef[] = []>(
|
|
|
155
159
|
|
|
156
160
|
// Always-on: cssClass + mutation
|
|
157
161
|
const { update, getValues } = useContext(LocalsUpdateContext);
|
|
162
|
+
const inIteration = useContext(ItemEditContext);
|
|
158
163
|
const renderOptions = useRenderOptions();
|
|
159
164
|
const renderNodes = (
|
|
160
165
|
nodes: ASTNode[],
|
|
@@ -190,7 +195,11 @@ export function defineMacro<const P extends readonly ParameterDef[] = []>(
|
|
|
190
195
|
id,
|
|
191
196
|
resolve,
|
|
192
197
|
cls: macroClass(config.name, className),
|
|
193
|
-
|
|
198
|
+
// Code run inside a {for} iteration edits the loop's items (#400)
|
|
199
|
+
mutate: inIteration
|
|
200
|
+
? (code: string) =>
|
|
201
|
+
runAsItemEdit(() => executeMutation(code, getValues(), update))
|
|
202
|
+
: (code: string) => executeMutation(code, getValues(), update),
|
|
194
203
|
update,
|
|
195
204
|
getValues,
|
|
196
205
|
wrap: (content: ComponentChildren): VNode<any> =>
|
package/src/execute-mutation.ts
CHANGED
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
} from './structural';
|
|
27
27
|
import { getByPath, setByPath } from './utils/object-path';
|
|
28
28
|
import { asNamespace, hasOwn } from './utils/namespace';
|
|
29
|
+
import { mapEntries, setMembers } from './utils/value-kinds';
|
|
29
30
|
|
|
30
31
|
type NamespaceName = keyof VariableNamespaces;
|
|
31
32
|
|
|
@@ -150,11 +151,11 @@ function deeplyFrozen(value: unknown, seen = new Set<object>()): boolean {
|
|
|
150
151
|
seen.add(value);
|
|
151
152
|
// What a Map or Set holds is not in its properties (#388)
|
|
152
153
|
if (value instanceof Map) {
|
|
153
|
-
for (const [key, entry] of value) {
|
|
154
|
+
for (const [key, entry] of mapEntries(value)) {
|
|
154
155
|
if (!deeplyFrozen(key, seen) || !deeplyFrozen(entry, seen)) return false;
|
|
155
156
|
}
|
|
156
157
|
} else if (value instanceof Set) {
|
|
157
|
-
for (const member of value) {
|
|
158
|
+
for (const member of setMembers(value)) {
|
|
158
159
|
if (!deeplyFrozen(member, seen)) return false;
|
|
159
160
|
}
|
|
160
161
|
}
|
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';
|
|
@@ -58,6 +58,22 @@ export const InlineContext = createContext(false);
|
|
|
58
58
|
*/
|
|
59
59
|
export const RawTextContext = createContext(false);
|
|
60
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
|
+
|
|
61
77
|
/**
|
|
62
78
|
* True inside an `<svg>` element. SVG attributes are case-sensitive and have
|
|
63
79
|
* no live form properties, so they are set as written (see splitAttributes).
|
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/runtime-errors.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// errors that would otherwise only reach the browser console.
|
|
3
3
|
|
|
4
4
|
import { errorMessage } from './utils/error-message';
|
|
5
|
+
import { createListeners } from './utils/listeners';
|
|
5
6
|
|
|
6
7
|
/** A runtime error shown on the page until the player dismisses it. */
|
|
7
8
|
export interface RuntimeError {
|
|
@@ -17,11 +18,11 @@ export interface RuntimeError {
|
|
|
17
18
|
|
|
18
19
|
let errors: readonly RuntimeError[] = [];
|
|
19
20
|
let nextId = 1;
|
|
20
|
-
const listeners =
|
|
21
|
+
const listeners = createListeners();
|
|
21
22
|
|
|
22
23
|
function update(next: readonly RuntimeError[]): void {
|
|
23
24
|
errors = next;
|
|
24
|
-
|
|
25
|
+
listeners.notify();
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
/**
|
|
@@ -67,9 +68,4 @@ export function getRuntimeErrors(): readonly RuntimeError[] {
|
|
|
67
68
|
}
|
|
68
69
|
|
|
69
70
|
/** Call `listener` whenever the shown errors change; returns unsubscribe. */
|
|
70
|
-
export
|
|
71
|
-
listeners.add(listener);
|
|
72
|
-
return () => {
|
|
73
|
-
listeners.delete(listener);
|
|
74
|
-
};
|
|
75
|
-
}
|
|
71
|
+
export const subscribeRuntimeErrors = listeners.subscribe;
|