@rohal12/spindle 0.59.7 → 0.59.8

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.
@@ -1,5 +1,5 @@
1
1
  import { render } from 'preact';
2
- import { useContext } from 'preact/hooks';
2
+ import { useContext, useEffect, useRef } from 'preact/hooks';
3
3
  import {
4
4
  renderNodes,
5
5
  LocalsUpdateContext,
@@ -19,7 +19,10 @@ import { DialogCloseContext } from '../PassageDialog';
19
19
  * Return a function that runs a macro body once, outside the passage tree:
20
20
  * it renders `children` into a detached node, so the body's macros ({set},
21
21
  * {if}, {goto}, ...) fire their side effects through the normal Preact
22
- * pipeline, then unmounts it. Used by {button} and {link} on click.
22
+ * pipeline. Used by {button} and {link} on click. The body stays mounted
23
+ * until the owning macro unmounts (its passage changes, say), so work it
24
+ * starts in an effect, such as the timer of a {timed} or {repeat}, can
25
+ * finish; the passage leaving cancels it.
23
26
  *
24
27
  * A detached render starts with no contexts, so the hook captures the ones
25
28
  * the owning macro sees and provides them again: the locals scope (read
@@ -41,6 +44,16 @@ export function useDetachedBody(): (children: ASTNode[]) => void {
41
44
  const repeat = useContext(RepeatContext);
42
45
  const closeDialog = useContext(DialogCloseContext);
43
46
 
47
+ const mounted = useRef<HTMLElement[]>([]);
48
+ useEffect(
49
+ () => () => {
50
+ for (const container of mounted.current.splice(0)) {
51
+ render(null, container);
52
+ }
53
+ },
54
+ [],
55
+ );
56
+
44
57
  const run = (children: ASTNode[]) => {
45
58
  const locals = liveLocalsView(updater.getValues);
46
59
  const container = document.createElement('div');
@@ -62,7 +75,7 @@ export function useDetachedBody(): (children: ASTNode[]) => void {
62
75
  </LocalsUpdateContext.Provider>,
63
76
  container,
64
77
  );
65
- render(null, container);
78
+ mounted.current.push(container);
66
79
  };
67
80
  return (children) => runWithCommittedMutations(() => run(children));
68
81
  }
package/src/expression.ts CHANGED
@@ -123,32 +123,22 @@ export function historyQueries(
123
123
  }
124
124
 
125
125
  let cachedFns: ExpressionFns | null = null;
126
- let cachedVisitCounts: Counts | null = null;
127
- let cachedRenderCounts: Counts | null = null;
128
126
 
127
+ /**
128
+ * The functions compiled code calls. The history queries read the counters
129
+ * when called, not when the functions were built: mutation code that
130
+ * navigates (`Story.goto()`) goes on with the counters of the new state,
131
+ * as the Story API does.
132
+ */
129
133
  export function buildExpressionFns() {
130
- const state = useStoryStore.getState();
131
- const { visitCounts, renderCounts } = state;
132
-
133
- if (
134
- cachedFns &&
135
- cachedVisitCounts === visitCounts &&
136
- cachedRenderCounts === renderCounts
137
- ) {
138
- return cachedFns;
139
- }
140
-
141
- cachedFns = {
134
+ cachedFns ??= {
142
135
  ...historyQueries(
143
- () => visitCounts,
144
- () => renderCounts,
136
+ () => useStoryStore.getState().visitCounts,
137
+ () => useStoryStore.getState().renderCounts,
145
138
  ),
146
139
  random,
147
140
  randomInt,
148
141
  };
149
- cachedVisitCounts = visitCounts;
150
- cachedRenderCounts = renderCounts;
151
-
152
142
  return cachedFns;
153
143
  }
154
144
 
@@ -221,8 +211,6 @@ export function clearExpressionCache(): void {
221
211
  fnCache.clear();
222
212
  clearTransformCache();
223
213
  cachedFns = null;
224
- cachedVisitCounts = null;
225
- cachedRenderCounts = null;
226
214
  }
227
215
 
228
216
  export function evaluateWithState(expr: string, state: StoryState): unknown {
@@ -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
+ }
@@ -713,14 +713,22 @@ const SESSION_KEY_PREFIX = 'spindle.session.';
713
713
  * encodePayload). Throws, like a save, when the state holds a value a save
714
714
  * cannot (a function, an instance of an unregistered class, a unique
715
715
  * symbol); the session then keeps its previous copy.
716
+ *
717
+ * A storage that is unavailable or full is returned, not thrown: the session
718
+ * keeps its previous copy then too, but the story can go on, so the caller
719
+ * decides how to tell the player.
716
720
  */
717
- export function saveSession(ifid: string, payload: SavePayload): void {
721
+ export function saveSession(
722
+ ifid: string,
723
+ payload: SavePayload,
724
+ ): { error: unknown } | undefined {
718
725
  const text = JSON.stringify(encodePayload(payload));
719
726
  try {
720
727
  sessionStorage.setItem(`${SESSION_KEY_PREFIX}${ifid}`, text);
721
- } catch {
722
- // sessionStorage unavailable or full — silently ignore
728
+ } catch (error) {
729
+ return { error };
723
730
  }
731
+ return undefined;
724
732
  }
725
733
 
726
734
  /**
@@ -889,13 +897,15 @@ async function clearAllDataNow(): Promise<void> {
889
897
  resetBackend();
890
898
 
891
899
  // Clear all Spindle session keys from sessionStorage
892
- if (typeof sessionStorage !== 'undefined') {
900
+ try {
893
901
  const toRemove: string[] = [];
894
902
  for (let i = 0; i < sessionStorage.length; i++) {
895
903
  const key = sessionStorage.key(i);
896
904
  if (key?.startsWith('spindle.session.')) toRemove.push(key);
897
905
  }
898
906
  for (const key of toRemove) sessionStorage.removeItem(key);
907
+ } catch {
908
+ // no sessionStorage (or access denied): nothing to clear
899
909
  }
900
910
  }
901
911
 
@@ -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();
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
@@ -259,7 +259,7 @@ let sessionMoments: SaveHistoryMoment[] = [];
259
259
  * Write the game to the session: the whole payload, encoded in one piece
260
260
  * on every navigation.
261
261
  */
262
- function persistSession(get: () => StoryState): void {
262
+ function persistSession(get: () => StoryState): { error: unknown } | undefined {
263
263
  const {
264
264
  storyData,
265
265
  currentPassage,
@@ -269,7 +269,7 @@ function persistSession(get: () => StoryState): void {
269
269
  visitCounts,
270
270
  renderCounts,
271
271
  } = get();
272
- if (!storyData) return;
272
+ if (!storyData) return undefined;
273
273
 
274
274
  // Trim cache when history shrank (navigate() drops discarded forward
275
275
  // moments itself, since a replacement branch may keep the same length)
@@ -294,7 +294,7 @@ function persistSession(get: () => StoryState): void {
294
294
  }
295
295
  }
296
296
 
297
- saveSession(storyData.ifid, {
297
+ return saveSession(storyData.ifid, {
298
298
  passage: currentPassage,
299
299
  variables,
300
300
  history: sessionMoments,
@@ -324,7 +324,11 @@ const NAVIGATION_ERROR_CONTEXT = 'The story could not go to another passage:';
324
324
  function persistSessionThen(get: () => StoryState, after?: () => void): void {
325
325
  let failure: { error: unknown } | undefined;
326
326
  try {
327
- persistSession(get);
327
+ const storageFailure = persistSession(get);
328
+ // The story goes on, but a reload would go back to the last copy
329
+ if (storageFailure) {
330
+ showRuntimeError(SESSION_ERROR_CONTEXT, storageFailure.error);
331
+ }
328
332
  } catch (error) {
329
333
  failure = { error };
330
334
  // The player sees it too: a reload would go back to the last moment
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;
@@ -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;