@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rohal12/spindle",
3
- "version": "0.59.14",
3
+ "version": "0.59.16",
4
4
  "type": "module",
5
5
  "description": "A Preact-based story format for Twine 2.",
6
6
  "license": "Unlicense",
@@ -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 Set, `[kind, contents, keys]`: the kind of
52
- // collection, its elements (Map entries as pairs) and its other own
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
- /** The kinds of collection a registered class may extend. */
159
- const COLLECTION_KINDS = {
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 CollectionKind = keyof typeof COLLECTION_KINDS;
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 collection its kind, elements and extra keys, read
169
- * with the built-in methods, which a subclass may override (#391).
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(Map.prototype.entries.call(value)), extra()];
223
+ return ['Map', Array.from(mapEntries(value)), extra()];
183
224
  }
184
225
  if (value instanceof Set) {
185
- return ['Set', Array.from(Set.prototype.values.call(value)), extra()];
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 collection of a registered class that a `[kind, contents, keys]`
245
- * list holds (see classData). A cycle through the collection revives it
246
- * before its list is complete (devalue holds unread parts as holes), so the
247
- * collection is kept per list and filled with what the list holds so far.
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 reviveCollection(name: string, ctor: Constructor, list: unknown[]) {
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(COLLECTION_KINDS, kind)
254
- ? COLLECTION_KINDS[kind as CollectionKind]
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
- made = Object.setPrototypeOf(new base(), ctor.prototype) as object;
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 reviveCollection(name, registeredCtor(name), data);
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 collection (see classData)
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
- if (
395
- !Array.isArray(data) ||
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 { renderNodes, NobrContext } from '../markup/render';
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
- <div
136
- ref={elRef}
137
- class="passage"
138
- data-passage={passage.name}
139
- data-tags={passage.tags.join(' ')}
140
- data-transition={dataTransition}
141
- >
142
- {headerContent && <div class="passage-header">{headerContent}</div>}
143
- {content}
144
- {footerContent && <div class="passage-footer">{footerContent}</div>}
145
- {doneContent && <div hidden>{doneContent}</div>}
146
- </div>
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 { renderNodes, NobrContext } from '../markup/render';
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">{content}</div>
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 { controlEditVersion, editedItem } from '../../utils/control-edits';
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 a
133
- // reader editing the item through a control: remounting would drop the
134
- // control's focus after the first keystroke.
135
- const since = keys.edits;
136
- const { variables } = readState();
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(content);
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 { addMacroTrigger, removeTrigger } from '../../triggers';
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
- const registered = hooks.useRef(false);
38
- if (!registered.current) {
39
- registered.current = true;
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);
@@ -10,7 +10,11 @@ import {
10
10
  useMemo,
11
11
  } from 'preact/hooks';
12
12
  import { useInterpolate } from './hooks/use-interpolate';
13
- import { noteControlEdit } from './utils/control-edits';
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
- mutate: (code: string) => executeMutation(code, getValues(), update),
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> =>
@@ -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
  }
@@ -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).
@@ -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; }
@@ -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 = new Set<() => void>();
21
+ const listeners = createListeners();
21
22
 
22
23
  function update(next: readonly RuntimeError[]): void {
23
24
  errors = next;
24
- for (const listener of listeners) listener();
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 function subscribeRuntimeErrors(listener: () => void): () => void {
71
- listeners.add(listener);
72
- return () => {
73
- listeners.delete(listener);
74
- };
75
- }
71
+ export const subscribeRuntimeErrors = listeners.subscribe;