@rohal12/spindle 0.59.7 → 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.
@@ -13,9 +13,23 @@ const FOCUSABLE = [
13
13
  '[tabindex]:not([tabindex="-1"])',
14
14
  ].join(',');
15
15
 
16
+ /**
17
+ * Whether CSS leaves `el` in the tab sequence: not `visibility: hidden`
18
+ * (inherited, so the computed value covers ancestors) and not inside a
19
+ * `display: none` element.
20
+ */
21
+ function isRendered(el: HTMLElement): boolean {
22
+ const { visibility } = getComputedStyle(el);
23
+ if (visibility === 'hidden' || visibility === 'collapse') return false;
24
+ for (let e: HTMLElement | null = el; e; e = e.parentElement) {
25
+ if (getComputedStyle(e).display === 'none') return false;
26
+ }
27
+ return true;
28
+ }
29
+
16
30
  function focusables(root: HTMLElement): HTMLElement[] {
17
31
  return Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE)).filter(
18
- (el) => !el.hidden && !el.closest('[hidden], [inert]'),
32
+ (el) => !el.hidden && !el.closest('[hidden], [inert]') && isRendered(el),
19
33
  );
20
34
  }
21
35
 
@@ -0,0 +1,23 @@
1
+ import { useStoryStore } from './store';
2
+ import { showRuntimeError } from './runtime-errors';
3
+
4
+ /**
5
+ * QuickSave and QuickLoad as the built-in buttons and hotkeys perform them:
6
+ * a failure is shown on the page (the Story API's save() and load() only
7
+ * reject, for the code that awaits them).
8
+ */
9
+ export async function quickSave(): Promise<void> {
10
+ try {
11
+ await useStoryStore.getState().save();
12
+ } catch (error) {
13
+ showRuntimeError('The game could not be saved:', error);
14
+ }
15
+ }
16
+
17
+ export async function quickLoad(): Promise<void> {
18
+ try {
19
+ await useStoryStore.getState().load();
20
+ } catch (error) {
21
+ showRuntimeError('The saved game could not be loaded:', error);
22
+ }
23
+ }
@@ -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. */
@@ -713,14 +750,22 @@ const SESSION_KEY_PREFIX = 'spindle.session.';
713
750
  * encodePayload). Throws, like a save, when the state holds a value a save
714
751
  * cannot (a function, an instance of an unregistered class, a unique
715
752
  * symbol); the session then keeps its previous copy.
753
+ *
754
+ * A storage that is unavailable or full is returned, not thrown: the session
755
+ * keeps its previous copy then too, but the story can go on, so the caller
756
+ * decides how to tell the player.
716
757
  */
717
- export function saveSession(ifid: string, payload: SavePayload): void {
758
+ export function saveSession(
759
+ ifid: string,
760
+ payload: SavePayload,
761
+ ): { error: unknown } | undefined {
718
762
  const text = JSON.stringify(encodePayload(payload));
719
763
  try {
720
764
  sessionStorage.setItem(`${SESSION_KEY_PREFIX}${ifid}`, text);
721
- } catch {
722
- // sessionStorage unavailable or full — silently ignore
765
+ } catch (error) {
766
+ return { error };
723
767
  }
768
+ return undefined;
724
769
  }
725
770
 
726
771
  /**
@@ -889,13 +934,15 @@ async function clearAllDataNow(): Promise<void> {
889
934
  resetBackend();
890
935
 
891
936
  // Clear all Spindle session keys from sessionStorage
892
- if (typeof sessionStorage !== 'undefined') {
937
+ try {
893
938
  const toRemove: string[] = [];
894
939
  for (let i = 0; i < sessionStorage.length; i++) {
895
940
  const key = sessionStorage.key(i);
896
941
  if (key?.startsWith('spindle.session.')) toRemove.push(key);
897
942
  }
898
943
  for (const key of toRemove) sessionStorage.removeItem(key);
944
+ } catch {
945
+ // no sessionStorage (or access denied): nothing to clear
899
946
  }
900
947
  }
901
948
 
@@ -410,8 +410,10 @@ function createIDBBackend(): StorageBackend {
410
410
  let _backend: StorageBackend | null = null;
411
411
 
412
412
  async function detectBackend(): Promise<StorageBackend> {
413
- if (typeof indexedDB !== 'undefined') {
414
- try {
413
+ // Even looking up a storage API can throw (a sandboxed frame without
414
+ // allow-same-origin denies the localStorage property), hence the try.
415
+ try {
416
+ if (typeof indexedDB !== 'undefined') {
415
417
  await new Promise<void>((resolve, reject) => {
416
418
  const req = indexedDB.open('__spindle_probe__', 1);
417
419
  req.onsuccess = () => {
@@ -422,21 +424,21 @@ async function detectBackend(): Promise<StorageBackend> {
422
424
  req.onerror = () => reject(req.error);
423
425
  });
424
426
  return createIDBBackend();
425
- } catch {
426
- // fall through
427
427
  }
428
+ } catch {
429
+ // fall through
428
430
  }
429
431
 
430
- if (typeof localStorage !== 'undefined') {
431
- try {
432
+ try {
433
+ if (typeof localStorage !== 'undefined') {
432
434
  const probeKey = '__spindle_probe__';
433
435
  localStorage.setItem(probeKey, '1');
434
436
  const val = localStorage.getItem(probeKey);
435
437
  localStorage.removeItem(probeKey);
436
438
  if (val === '1') return createLocalStorageBackend();
437
- } catch {
438
- // fall through
439
439
  }
440
+ } catch {
441
+ // fall through
440
442
  }
441
443
 
442
444
  return createMemoryBackend();
@@ -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/settings.ts CHANGED
@@ -39,7 +39,11 @@ function storageKey(): string {
39
39
  }
40
40
 
41
41
  function persist(): void {
42
- localStorage.setItem(storageKey(), JSON.stringify(values));
42
+ try {
43
+ localStorage.setItem(storageKey(), JSON.stringify(values));
44
+ } catch {
45
+ // storage unavailable or full: the setting holds for this session only
46
+ }
43
47
  }
44
48
 
45
49
  function loadFromStorage(): void {
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 {
@@ -259,7 +261,7 @@ let sessionMoments: SaveHistoryMoment[] = [];
259
261
  * Write the game to the session: the whole payload, encoded in one piece
260
262
  * on every navigation.
261
263
  */
262
- function persistSession(get: () => StoryState): void {
264
+ function persistSession(get: () => StoryState): { error: unknown } | undefined {
263
265
  const {
264
266
  storyData,
265
267
  currentPassage,
@@ -269,7 +271,7 @@ function persistSession(get: () => StoryState): void {
269
271
  visitCounts,
270
272
  renderCounts,
271
273
  } = get();
272
- if (!storyData) return;
274
+ if (!storyData) return undefined;
273
275
 
274
276
  // Trim cache when history shrank (navigate() drops discarded forward
275
277
  // moments itself, since a replacement branch may keep the same length)
@@ -294,7 +296,7 @@ function persistSession(get: () => StoryState): void {
294
296
  }
295
297
  }
296
298
 
297
- saveSession(storyData.ifid, {
299
+ return saveSession(storyData.ifid, {
298
300
  passage: currentPassage,
299
301
  variables,
300
302
  history: sessionMoments,
@@ -302,6 +304,7 @@ function persistSession(get: () => StoryState): void {
302
304
  visitCounts,
303
305
  renderCounts,
304
306
  prng: snapshotPRNG(),
307
+ watchers: savedMacroWatchers(),
305
308
  });
306
309
  }
307
310
 
@@ -324,7 +327,11 @@ const NAVIGATION_ERROR_CONTEXT = 'The story could not go to another passage:';
324
327
  function persistSessionThen(get: () => StoryState, after?: () => void): void {
325
328
  let failure: { error: unknown } | undefined;
326
329
  try {
327
- persistSession(get);
330
+ const storageFailure = persistSession(get);
331
+ // The story goes on, but a reload would go back to the last copy
332
+ if (storageFailure) {
333
+ showRuntimeError(SESSION_ERROR_CONTEXT, storageFailure.error);
334
+ }
328
335
  } catch (error) {
329
336
  failure = { error };
330
337
  // The player sees it too: a reload would go back to the last moment
@@ -943,6 +950,21 @@ export interface StoryState {
943
950
  clearDeferredRender: () => void;
944
951
  }
945
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
+
946
968
  const NAMESPACE_KEYS = ['variables', 'temporary', 'transient'] as const;
947
969
 
948
970
  type StoryRecipe = (draft: Draft<StoryState>) => void;
@@ -1597,6 +1619,7 @@ export const useStoryStore = create<StoryState>()(
1597
1619
  visitCounts: { ...visitCounts },
1598
1620
  renderCounts: { ...renderCounts },
1599
1621
  prng: snapshotPRNG(),
1622
+ watchers: savedMacroWatchers(),
1600
1623
  };
1601
1624
  },
1602
1625
 
@@ -1614,12 +1637,21 @@ export const useStoryStore = create<StoryState>()(
1614
1637
  slot?: string,
1615
1638
  playthroughId?: string,
1616
1639
  ) => {
1617
- const replacement = slotLoadApplying ?? ++stateReplacementsIssued;
1640
+ const applying = slotLoadApplying;
1618
1641
  slotLoadApplying = null;
1642
+ const replacement = applying ?? ++stateReplacementsIssued;
1619
1643
  if (payload.history.length === 0) {
1620
1644
  console.warn('loadFromPayload: rejecting payload with empty history');
1621
1645
  return;
1622
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
+ }
1623
1655
  latestStateApplied = replacement;
1624
1656
 
1625
1657
  emit('beforeload', slot);
@@ -1680,7 +1712,10 @@ export const useStoryStore = create<StoryState>()(
1680
1712
  state.transient = createNamespace(deepClone(get().transientDefaults));
1681
1713
  });
1682
1714
 
1683
- // 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);
1684
1719
  reinitTriggerState();
1685
1720
 
1686
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
@@ -261,6 +261,11 @@ tw-storydata {
261
261
  max-width: 400px;
262
262
  }
263
263
 
264
+ /* Keep the first control clear of the close button above it */
265
+ .dialog-settings .dialog-body {
266
+ padding-top: 2.25em;
267
+ }
268
+
264
269
  .dialog-close {
265
270
  position: absolute;
266
271
  top: 0.75em;
@@ -566,6 +571,15 @@ tw-storydata {
566
571
  background: rgba(100, 181, 246, 0.05);
567
572
  }
568
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
+
569
583
  .saves-status {
570
584
  padding: 0.5em 1.25em;
571
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 {
@@ -36,7 +36,14 @@ export function parseWidgetDef(rawArgs: string): WidgetDef {
36
36
  return widgetDef(parseMacroArgs(rawArgs, WIDGET_PARAMETERS));
37
37
  }
38
38
 
39
- const BLOCK_WIDGET = /\{widget\s+["']?(\w+)["']?[^}]*\}([\s\S]*?)\{\/widget\}/g;
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*\}/;
40
47
 
41
48
  /**
42
49
  * The names of the block widgets (those whose body renders `{@children}`)
@@ -52,8 +59,13 @@ export function blockWidgetNames(
52
59
  if (passage.name !== 'StoryInit' && !passage.tags?.includes('widget')) {
53
60
  continue;
54
61
  }
55
- for (const match of passage.content.matchAll(BLOCK_WIDGET)) {
56
- if (/\{@children\}/.test(match[2]!)) names.push(match[1]!);
62
+ for (const match of passage.content.matchAll(WIDGET_DEFINITION)) {
63
+ if (!CHILDREN_PLACEHOLDER.test(match[2]!)) continue;
64
+ try {
65
+ names.push(parseWidgetDef(match[1]!).name);
66
+ } catch {
67
+ // The {widget} macro reports a definition it cannot read
68
+ }
57
69
  }
58
70
  }
59
71
  return names;