@rohal12/spindle 0.59.8 → 0.59.9

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.
@@ -83,9 +83,9 @@ export function PassageDialog({
83
83
 
84
84
  const handleBackdrop = (e: MouseEvent) => {
85
85
  if (!dismissible) return;
86
- if ((e.target as HTMLElement).classList.contains('dialog-overlay')) {
87
- stableOnClose();
88
- }
86
+ // Only a click on this overlay itself: a nested dialog's backdrop click
87
+ // bubbles through the outer overlay, whose target is not its own.
88
+ if (e.target === e.currentTarget) stableOnClose();
89
89
  };
90
90
 
91
91
  const cls = panelClass ? `dialog-panel ${panelClass}` : 'dialog-panel';
@@ -105,6 +105,7 @@ export function PassageDialog({
105
105
  >
106
106
  {showCloseButton && (
107
107
  <button
108
+ type="button"
108
109
  class="dialog-close"
109
110
  aria-label="Close"
110
111
  onClick={stableOnClose}
@@ -26,6 +26,7 @@ defineMacro({
26
26
 
27
27
  return (
28
28
  <button
29
+ type="button"
29
30
  id={ctx.id}
30
31
  class={ctx.cls}
31
32
  onClick={handleClick}
@@ -32,6 +32,7 @@ defineMacro({
32
32
 
33
33
  return (
34
34
  <button
35
+ type="button"
35
36
  id={ctx.id}
36
37
  class={ctx.cls}
37
38
  onClick={handleClick}
@@ -22,6 +22,7 @@ defineMacro({
22
22
  return (
23
23
  <>
24
24
  <button
25
+ type="button"
25
26
  id={ctx.id}
26
27
  class={ctx.cls}
27
28
  onClick={() => setOpen(true)}
@@ -1,5 +1,5 @@
1
1
  import { defineMacro } from '../../define-macro';
2
- import { logMacroError } from './MacroError';
2
+ import { useRunOnce } from './MacroError';
3
3
 
4
4
  defineMacro({
5
5
  name: 'do',
@@ -8,16 +8,8 @@ defineMacro({
8
8
  // The parser keeps the body verbatim as a single text node
9
9
  const code = ctx.collectText(children);
10
10
 
11
- ctx.hooks.useLayoutEffect(() => {
12
- // Before the code runs: it may navigate away from this passage
13
- const location = ctx.sourceLocation();
14
- try {
15
- ctx.mutate(code);
16
- } catch (err) {
17
- logMacroError('do', err, location);
18
- }
19
- }, []);
20
-
21
- return null;
11
+ // During the first render, like {set}: the macros after it in the
12
+ // passage run later and see its writes (a layout effect ran after them).
13
+ return useRunOnce('do', '', () => ctx.mutate(code));
22
14
  },
23
15
  });
@@ -49,7 +49,7 @@ export function useRunOnce(macro: string, rawArgs: string, effect: () => void) {
49
49
  effect();
50
50
  } catch (error) {
51
51
  failure.current = { error };
52
- logMacroError(`${macro} ${rawArgs}`, error, location);
52
+ logMacroError(`${macro} ${rawArgs}`.trim(), error, location);
53
53
  }
54
54
  }
55
55
 
@@ -63,6 +63,7 @@ export function defineMenubarAction(config: MenubarActionConfig) {
63
63
  return (
64
64
  <>
65
65
  <button
66
+ type="button"
66
67
  id={ctx.id}
67
68
  class={cls}
68
69
  onClick={perform}
@@ -8,7 +8,7 @@ import { resolveTransition, type ResolvedTransition } from '../../transition';
8
8
  /**
9
9
  * Create a snapshot clone of a .passage element for outgoing transitions.
10
10
  * - Strips `id` attributes from the clone tree
11
- * - Disables interaction (pointer-events, user-select)
11
+ * - Disables interaction (pointer-events, user-select, inert, aria-hidden)
12
12
  * - Pauses/mutes any <audio>/<video>
13
13
  * - Adds `.passage-snapshot` class and `data-transition` attribute
14
14
  */
@@ -28,6 +28,10 @@ function createSnapshot(
28
28
  // Disable interaction
29
29
  clone.style.pointerEvents = 'none';
30
30
  clone.style.userSelect = 'none';
31
+ // Visual only: out of the tab order and the accessibility tree, and its
32
+ // controls (copied with their inline handlers) can not be activated
33
+ clone.setAttribute('inert', '');
34
+ clone.setAttribute('aria-hidden', 'true');
31
35
 
32
36
  // Pause/mute media
33
37
  const mediaEls = clone.querySelectorAll('audio, video');
@@ -21,6 +21,7 @@ import {
21
21
  saveWithHooks,
22
22
  type PlaythroughGroup,
23
23
  } from '../../saves/save-manager';
24
+ import { getBackendType } from '../../saves/storage';
24
25
  import { DialogCloseContext } from '../PassageDialog';
25
26
  import { defineMacro } from '../../define-macro';
26
27
  import { errorMessage } from '../../utils/error-message';
@@ -71,10 +72,15 @@ export function SaveManagerContent() {
71
72
  'loadFromPayload',
72
73
  );
73
74
  const ifid = storyData?.ifid ?? '';
75
+ // Saves held in memory only (no browser storage is available) are lost
76
+ // when the page is reloaded or closed: told to the player, who can export
77
+ const [temporary, setTemporary] = useState(false);
74
78
 
75
79
  const refresh = useCallback(async () => {
76
80
  if (!ifid) return;
77
81
  const data = await getSavesGrouped(ifid);
82
+ // After the read: the storage in use is known by now
83
+ setTemporary(getBackendType() === 'memory');
78
84
  setGroups(data);
79
85
  setLoading(false);
80
86
  }, [ifid]);
@@ -131,7 +137,7 @@ export function SaveManagerContent() {
131
137
  // Queued now, in call order with other storage operations
132
138
  return createSave(ifid, playthrough, payload);
133
139
  });
134
- showStatus('Save created');
140
+ showStatus(temporary ? 'Save created (temporary)' : 'Save created');
135
141
  await refresh();
136
142
  } catch {
137
143
  showStatus('Failed to create save', 'error');
@@ -276,12 +282,14 @@ export function SaveManagerContent() {
276
282
  <div class="saves-header-left">
277
283
  <div class="saves-mode-toggle">
278
284
  <button
285
+ type="button"
279
286
  class={mode === 'save' ? 'active' : ''}
280
287
  onClick={() => setMode('save')}
281
288
  >
282
289
  Save
283
290
  </button>
284
291
  <button
292
+ type="button"
285
293
  class={mode === 'load' ? 'active' : ''}
286
294
  onClick={() => setMode('load')}
287
295
  >
@@ -291,8 +299,19 @@ export function SaveManagerContent() {
291
299
  </div>
292
300
  </div>
293
301
 
302
+ {temporary && (
303
+ <div
304
+ class="saves-notice"
305
+ role="status"
306
+ >
307
+ Saves can not be kept in this browser: they are lost when the page is
308
+ reloaded or closed. Export a save to keep it.
309
+ </div>
310
+ )}
311
+
294
312
  <div class="saves-toolbar">
295
313
  <button
314
+ type="button"
296
315
  class="saves-toolbar-button"
297
316
  onClick={handleImport}
298
317
  >
@@ -376,6 +395,7 @@ export function SaveManagerContent() {
376
395
  <div class="save-slot-actions">
377
396
  {mode === 'save' ? (
378
397
  <button
398
+ type="button"
379
399
  class="save-slot-action primary"
380
400
  onClick={() => handleOverwrite(save.meta.id)}
381
401
  >
@@ -383,6 +403,7 @@ export function SaveManagerContent() {
383
403
  </button>
384
404
  ) : (
385
405
  <button
406
+ type="button"
386
407
  class="save-slot-action primary"
387
408
  onClick={() => handleLoad(save)}
388
409
  >
@@ -390,18 +411,21 @@ export function SaveManagerContent() {
390
411
  </button>
391
412
  )}
392
413
  <button
414
+ type="button"
393
415
  class="save-slot-action"
394
416
  onClick={() => handleRenameStart(save)}
395
417
  >
396
418
  Rename
397
419
  </button>
398
420
  <button
421
+ type="button"
399
422
  class="save-slot-action"
400
423
  onClick={() => handleExport(save.meta.id)}
401
424
  >
402
425
  Export
403
426
  </button>
404
427
  <button
428
+ type="button"
405
429
  class="save-slot-action danger"
406
430
  onClick={() => handleDelete(save.meta.id)}
407
431
  >
@@ -413,6 +437,7 @@ export function SaveManagerContent() {
413
437
 
414
438
  {mode === 'save' && isCurrentPt && (
415
439
  <button
440
+ type="button"
416
441
  class="save-slot-new"
417
442
  onClick={handleNewSave}
418
443
  >
@@ -432,6 +457,7 @@ export function SaveManagerContent() {
432
457
  <div class="playthrough-group">
433
458
  <div class="playthrough-saves">
434
459
  <button
460
+ type="button"
435
461
  class="save-slot-new"
436
462
  onClick={handleNewSave}
437
463
  >
@@ -1,15 +1,54 @@
1
1
  import { defineMacro } from '../../define-macro';
2
2
  import { DELAY_PARAMETER } from './macro-args';
3
3
 
4
+ /**
5
+ * The clip-path that shows the first `count` characters of `root`'s text in
6
+ * reading order: the boxes the browser laid out for them, each wrapped line
7
+ * as far as its last revealed character, as one path in the coordinates of
8
+ * `inner`'s box. Undefined where there is no layout to measure.
9
+ */
10
+ function revealedPath(
11
+ root: HTMLElement,
12
+ inner: HTMLElement,
13
+ count: number,
14
+ ): string | undefined {
15
+ const range = document.createRange();
16
+ if (!range.getClientRects) return undefined;
17
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
18
+ let left = count;
19
+ let end: { node: Node; offset: number } | undefined;
20
+ for (let n = walker.nextNode(); n && left > 0; n = walker.nextNode()) {
21
+ const length = n.nodeValue?.length ?? 0;
22
+ end = { node: n, offset: Math.min(left, length) };
23
+ left -= length;
24
+ }
25
+ if (!end) return 'inset(0 100% 0 0)';
26
+ range.setStart(root, 0);
27
+ range.setEnd(end.node, end.offset);
28
+ const rects = range.getClientRects();
29
+ if (rects.length === 0) return undefined;
30
+ const origin = inner.getBoundingClientRect();
31
+ let path = '';
32
+ for (const r of Array.from(rects)) {
33
+ if (r.width === 0 && r.height === 0) continue;
34
+ const x = (r.left - origin.left).toFixed(2);
35
+ const y = (r.top - origin.top).toFixed(2);
36
+ path += `M${x} ${y}h${r.width.toFixed(2)}v${r.height.toFixed(2)}h-${r.width.toFixed(2)}z`;
37
+ }
38
+ return path ? `path("${path}")` : undefined;
39
+ }
40
+
4
41
  defineMacro({
5
42
  name: 'type',
6
43
  block: true,
7
44
  interpolate: true,
8
45
  parameters: [DELAY_PARAMETER],
9
46
  render({ children = [] }, ctx) {
10
- const { useState, useEffect, useRef } = ctx.hooks;
47
+ const { useState, useEffect, useLayoutEffect, useRef } = ctx.hooks;
11
48
  const speed = ctx.args.delay ?? 0;
12
49
  const containerRef = useRef<HTMLSpanElement>(null);
50
+ const innerRef = useRef<HTMLSpanElement>(null);
51
+ const [clip, setClip] = useState<string | undefined>();
13
52
  const [totalChars, setTotalChars] = useState(0);
14
53
  const [visibleChars, setVisibleChars] = useState(0);
15
54
  const visibleCharsRef = useRef(0);
@@ -44,6 +83,21 @@ defineMacro({
44
83
 
45
84
  const done = visibleChars >= totalChars && totalChars > 0;
46
85
 
86
+ // Clip to the characters revealed so far, as laid out: remeasured when
87
+ // the count changes and when the width the text wraps in does.
88
+ useLayoutEffect(() => {
89
+ const root = containerRef.current;
90
+ const inner = innerRef.current;
91
+ if (!root || !inner || totalChars === 0 || done) {
92
+ setClip(undefined);
93
+ return;
94
+ }
95
+ const measure = () => setClip(revealedPath(root, inner, visibleChars));
96
+ measure();
97
+ window.addEventListener('resize', measure);
98
+ return () => window.removeEventListener('resize', measure);
99
+ }, [visibleChars, totalChars, done]);
100
+
47
101
  const cls = [
48
102
  'macro-type',
49
103
  done ? 'macro-type-done' : '',
@@ -57,19 +111,14 @@ defineMacro({
57
111
  id={ctx.id}
58
112
  class={cls}
59
113
  ref={containerRef}
60
- style={{
61
- clipPath: totalChars > 0 && !done ? undefined : undefined,
62
- }}
63
114
  >
64
115
  <span
116
+ ref={innerRef}
65
117
  class="macro-type-inner"
66
118
  style={{
67
119
  display: 'inline',
68
120
  visibility: totalChars === 0 ? 'hidden' : 'visible',
69
- clipPath:
70
- totalChars > 0 && !done
71
- ? `inset(0 ${((totalChars - visibleChars) / totalChars) * 100}% 0 0)`
72
- : undefined,
121
+ clipPath: clip,
73
122
  }}
74
123
  >
75
124
  {ctx.renderInlineNodes(children)}
@@ -135,6 +135,14 @@ const isMoment = (value: unknown): boolean =>
135
135
  typeof value.timestamp === 'number' &&
136
136
  isOptionalPRNGSnapshot(value.prng);
137
137
 
138
+ const isWatchers = (value: unknown): boolean =>
139
+ value === undefined ||
140
+ (Array.isArray(value) &&
141
+ Array.from(value).every(
142
+ (w) =>
143
+ isRecord(w) && typeof w.condition === 'string' && isRecord(w.options),
144
+ ));
145
+
138
146
  const isCountsMap = (value: unknown): boolean =>
139
147
  value === undefined ||
140
148
  (value instanceof Map &&
@@ -166,7 +174,8 @@ function isPayload(value: unknown): value is Record<string, unknown> {
166
174
  return (
167
175
  isCountsMap(value.visitCounts) &&
168
176
  isCountsMap(value.renderCounts) &&
169
- isOptionalPRNGSnapshot(value.prng)
177
+ isOptionalPRNGSnapshot(value.prng) &&
178
+ isWatchers(value.watchers)
170
179
  );
171
180
  }
172
181
 
@@ -134,17 +134,18 @@ async function startNewPlaythroughNow(
134
134
  id: string = randomUUID(),
135
135
  ): Promise<string> {
136
136
  const backend = await getBackend();
137
- const num = await nextPlaythroughNumber(ifid);
138
-
139
- const record: PlaythroughRecord = {
140
- id,
141
- ifid,
142
- createdAt: new Date().toISOString(),
143
- label: `Playthrough ${num}`,
144
- };
145
-
146
- await backend.putPlaythrough(record);
147
- await backend.setMeta(playthroughCountKey(ifid), num);
137
+ // Taking the next number is a read-modify-write other tabs do too
138
+ await withTabLock(`spindle-playthrough-count:${ifid}`, async () => {
139
+ const num = await nextPlaythroughNumber(ifid);
140
+ const record: PlaythroughRecord = {
141
+ id,
142
+ ifid,
143
+ createdAt: new Date().toISOString(),
144
+ label: `Playthrough ${num}`,
145
+ };
146
+ await backend.putPlaythrough(record);
147
+ await backend.setMeta(playthroughCountKey(ifid), num);
148
+ });
148
149
  await backend.setMeta(currentPlaythroughKey(ifid), id);
149
150
  return id;
150
151
  }
@@ -503,15 +504,51 @@ function slotMetaKey(ifid: string, slot?: string): string {
503
504
 
504
505
  const slotIndexKey = (ifid: string) => `${SLOT_INDEX_KEY_PREFIX}${ifid}`;
505
506
 
506
- /** Read-modify-write the per-story slot index. */
507
+ /**
508
+ * Run `op` holding the browser's lock `name`, so other tabs of the story
509
+ * (the operation queue only orders this document's operations) wait for it.
510
+ * Runs `op` without a lock where the browser offers none (or refuses it, as
511
+ * in a sandboxed frame, which has no other tabs sharing its storage).
512
+ */
513
+ async function withTabLock<T>(name: string, op: () => Promise<T>): Promise<T> {
514
+ let locks: LockManager | undefined;
515
+ try {
516
+ locks = navigator.locks;
517
+ } catch {
518
+ // Access refused: no lock
519
+ }
520
+ if (!locks) return op();
521
+ let started = false;
522
+ try {
523
+ return await locks.request(name, () => {
524
+ started = true;
525
+ return op();
526
+ });
527
+ } catch (err) {
528
+ // Only a failure to take the lock falls back; op's own errors are its
529
+ if (started) throw err;
530
+ return op();
531
+ }
532
+ }
533
+
534
+ /**
535
+ * Read-modify-write the per-story slot index. Under a lock shared by the
536
+ * story's tabs: two tabs adding a slot at once would both read the old index
537
+ * and the later write would drop the other's slot.
538
+ */
507
539
  async function updateSlotIndex(
508
540
  ifid: string,
509
541
  update: (slots: string[]) => string[],
510
542
  ): Promise<void> {
511
543
  const backend = await getBackend();
512
- const existing = (await backend.getMeta<string[]>(slotIndexKey(ifid))) ?? [];
513
- const updated = update(existing);
514
- if (updated !== existing) await backend.setMeta(slotIndexKey(ifid), updated);
544
+ await withTabLock(`spindle-slot-index:${ifid}`, async () => {
545
+ const existing =
546
+ (await backend.getMeta<string[]>(slotIndexKey(ifid))) ?? [];
547
+ const updated = update(existing);
548
+ if (updated !== existing) {
549
+ await backend.setMeta(slotIndexKey(ifid), updated);
550
+ }
551
+ });
515
552
  }
516
553
 
517
554
  /** Every slot: the default one (undefined), then those in the slot index. */
@@ -1,4 +1,5 @@
1
1
  import type { PRNGSnapshot } from '../prng';
2
+ import type { SavedWatcher } from '../triggers';
2
3
  import { hasOwn } from '../utils/namespace';
3
4
  import {
4
5
  checkFormatVersion,
@@ -25,6 +26,8 @@ export interface SavePayload {
25
26
  visitCounts?: Record<string, number>;
26
27
  renderCounts?: Record<string, number>;
27
28
  prng?: PRNGSnapshot | null;
29
+ /** The {watch} macro watchers registered (absent in older saves). */
30
+ watchers?: SavedWatcher[];
28
31
  }
29
32
 
30
33
  export interface SaveMeta {
package/src/store.ts CHANGED
@@ -22,6 +22,8 @@ import { emit } from './event-emitter';
22
22
  import {
23
23
  resetTriggers,
24
24
  checkTriggersOnNavigation,
25
+ restoreMacroWatchers,
26
+ savedMacroWatchers,
25
27
  reinitTriggerState,
26
28
  } from './triggers';
27
29
  import {
@@ -302,6 +304,7 @@ function persistSession(get: () => StoryState): { error: unknown } | undefined {
302
304
  visitCounts,
303
305
  renderCounts,
304
306
  prng: snapshotPRNG(),
307
+ watchers: savedMacroWatchers(),
305
308
  });
306
309
  }
307
310
 
@@ -947,6 +950,21 @@ export interface StoryState {
947
950
  clearDeferredRender: () => void;
948
951
  }
949
952
 
953
+ /**
954
+ * The first passage of `payload` (its current passage, then its history)
955
+ * that `storyData` does not have, as when the story was updated since the
956
+ * save; undefined if the story has them all (or is not loaded).
957
+ */
958
+ export function missingPassage(
959
+ storyData: StoryData | null | undefined,
960
+ payload: SavePayload,
961
+ ): string | undefined {
962
+ if (!storyData) return undefined;
963
+ return [payload.passage, ...payload.history.map((m) => m.passage)].find(
964
+ (name) => !storyData.passages.has(name),
965
+ );
966
+ }
967
+
950
968
  const NAMESPACE_KEYS = ['variables', 'temporary', 'transient'] as const;
951
969
 
952
970
  type StoryRecipe = (draft: Draft<StoryState>) => void;
@@ -1601,6 +1619,7 @@ export const useStoryStore = create<StoryState>()(
1601
1619
  visitCounts: { ...visitCounts },
1602
1620
  renderCounts: { ...renderCounts },
1603
1621
  prng: snapshotPRNG(),
1622
+ watchers: savedMacroWatchers(),
1604
1623
  };
1605
1624
  },
1606
1625
 
@@ -1618,12 +1637,21 @@ export const useStoryStore = create<StoryState>()(
1618
1637
  slot?: string,
1619
1638
  playthroughId?: string,
1620
1639
  ) => {
1621
- const replacement = slotLoadApplying ?? ++stateReplacementsIssued;
1640
+ const applying = slotLoadApplying;
1622
1641
  slotLoadApplying = null;
1642
+ const replacement = applying ?? ++stateReplacementsIssued;
1623
1643
  if (payload.history.length === 0) {
1624
1644
  console.warn('loadFromPayload: rejecting payload with empty history');
1625
1645
  return;
1626
1646
  }
1647
+ // Before anything is replaced: an incompatible payload leaves the
1648
+ // running game as it is
1649
+ const missing = missingPassage(get().storyData, payload);
1650
+ if (missing !== undefined) {
1651
+ throw new Error(
1652
+ `The save refers to the passage "${missing}", which this version of the story does not have`,
1653
+ );
1654
+ }
1627
1655
  latestStateApplied = replacement;
1628
1656
 
1629
1657
  emit('beforeload', slot);
@@ -1684,7 +1712,10 @@ export const useStoryStore = create<StoryState>()(
1684
1712
  state.transient = createNamespace(deepClone(get().transientDefaults));
1685
1713
  });
1686
1714
 
1687
- // Loaded state is not a change watchers react to
1715
+ // The watchers of the loaded game (a payload without any, from an
1716
+ // older version, leaves the registered ones), then: loaded state is
1717
+ // not a change watchers react to
1718
+ if (payload.watchers) restoreMacroWatchers(payload.watchers);
1688
1719
  reinitTriggerState();
1689
1720
 
1690
1721
  // The next navigate() diffs from the snapshot recorded for the current
package/src/story-init.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { h, render } from 'preact';
2
- import { useStoryStore, recordStoryInitState } from './store';
2
+ import { useStoryStore, recordStoryInitState, missingPassage } from './store';
3
3
  import { parseMarkup } from './markup/parse';
4
4
  import { renderNodes } from './markup/render';
5
5
  import { setSaveTitlePassage } from './saves/save-manager';
@@ -71,6 +71,18 @@ export function executeStoryInit() {
71
71
  export function initializeStory(session?: SavePayload): void {
72
72
  executeStoryInit();
73
73
 
74
+ // A session that refers to a passage the story no longer has (it was
75
+ // updated since) is dropped: the game starts from the beginning
76
+ const missing = session
77
+ ? missingPassage(useStoryStore.getState().storyData, session)
78
+ : undefined;
79
+ if (missing !== undefined) {
80
+ console.warn(
81
+ `spindle: discarding the saved session: the story no longer has the passage "${missing}"`,
82
+ );
83
+ session = undefined;
84
+ }
85
+
74
86
  if (session) {
75
87
  useStoryStore.getState().loadFromPayload(session);
76
88
  }
package/src/structural.ts CHANGED
@@ -85,7 +85,9 @@ export function deepClone<T>(value: T, options: DeepCloneOptions = {}): T {
85
85
  function cloneBuiltin(val: object): object | undefined {
86
86
  if (val instanceof Date) return keep(val, new Date(val.getTime()));
87
87
  if (val instanceof RegExp) {
88
- return keep(val, new RegExp(val.source, val.flags));
88
+ const copy = new RegExp(val.source, val.flags);
89
+ copy.lastIndex = val.lastIndex; // the scanning cursor of g/y patterns
90
+ return keep(val, copy);
89
91
  }
90
92
  if (val instanceof ArrayBuffer) return keep(val, val.slice(0));
91
93
  if (ArrayBuffer.isView(val)) {
@@ -343,7 +345,9 @@ function equalBuiltin(
343
345
  assumed: Pairs,
344
346
  ): boolean | undefined {
345
347
  if (a instanceof Date) return Object.is(a.getTime(), (b as Date).getTime());
346
- if (a instanceof RegExp) return String(a) === String(b);
348
+ if (a instanceof RegExp) {
349
+ return String(a) === String(b) && a.lastIndex === (b as RegExp).lastIndex;
350
+ }
347
351
  if (ArrayBuffer.isView(a)) {
348
352
  // A view is its place in its backing buffer, and the whole buffer (which
349
353
  // other views can share): equal only if both match.
package/src/styles.css CHANGED
@@ -571,6 +571,15 @@ tw-storydata {
571
571
  background: rgba(100, 181, 246, 0.05);
572
572
  }
573
573
 
574
+ .saves-notice {
575
+ padding: 0.5em 1.25em;
576
+ font-size: 0.8em;
577
+ color: #ffb74d;
578
+ border-bottom: 1px solid rgba(255, 183, 77, 0.3);
579
+ background: rgba(255, 183, 77, 0.08);
580
+ flex-shrink: 0;
581
+ }
582
+
574
583
  .saves-status {
575
584
  padding: 0.5em 1.25em;
576
585
  font-size: 0.8em;
package/src/triggers.ts CHANGED
@@ -115,6 +115,36 @@ export function addMacroTrigger(
115
115
  registerTrigger(condition, options, macroKey);
116
116
  }
117
117
 
118
+ /** A {watch} macro's watcher as a save and the session hold it. */
119
+ export interface SavedWatcher {
120
+ condition: string;
121
+ options: WatchOptions;
122
+ }
123
+
124
+ /**
125
+ * The {watch} macro watchers now registered, for a save or the session:
126
+ * watchers outlive the passage that declared them, so a game restored
127
+ * where it stopped needs the ones its earlier passages registered (and
128
+ * not the ones {unwatch} or `once` removed).
129
+ */
130
+ export function savedMacroWatchers(): SavedWatcher[] {
131
+ return triggers
132
+ .filter((t) => t.macroKey !== undefined && t.options)
133
+ .map((t) => ({ condition: t.condition, options: { ...t.options } }));
134
+ }
135
+
136
+ /**
137
+ * Make the {watch} macro watchers those of a loaded game: the ones of
138
+ * `watchers` replace the registered ones (watchers added by Story.watch
139
+ * code stay). The passage shown mounts its own {watch} macros again.
140
+ */
141
+ export function restoreMacroWatchers(watchers: readonly SavedWatcher[]): void {
142
+ triggers = triggers.filter((t) => t.macroKey === undefined);
143
+ for (const { condition, options } of watchers) {
144
+ addMacroTrigger(condition, options);
145
+ }
146
+ }
147
+
118
148
  export function removeTrigger(name: string): void {
119
149
  triggers = triggers.filter((t) => t.name !== name);
120
150
  }
@@ -49,7 +49,7 @@ function keyOf(val: unknown, ancestors: object[]): string {
49
49
  if (depth !== -1) return `<cycle ${ancestors.length - depth}>`;
50
50
 
51
51
  if (val instanceof Date) return `Date(${val.getTime()})`;
52
- if (val instanceof RegExp) return `RegExp(${String(val)})`;
52
+ if (val instanceof RegExp) return `RegExp(${String(val)}@${val.lastIndex})`;
53
53
 
54
54
  ancestors.push(obj);
55
55
  try {