@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.
- package/dist/pkg/format.js +1 -1
- package/dist/pkg/headless.js +136 -50
- package/dist/pkg/story-variables.js +13 -2
- package/package.json +1 -1
- package/src/components/App.tsx +4 -4
- package/src/components/macros/MenubarButtons.tsx +8 -5
- package/src/components/macros/PassageDisplay.tsx +14 -4
- package/src/components/macros/SaveManager.tsx +15 -2
- package/src/components/macros/detached-body.tsx +16 -3
- package/src/expression.ts +9 -21
- package/src/hooks/use-modal-focus.ts +15 -1
- package/src/quick-actions.ts +23 -0
- package/src/saves/save-manager.ts +14 -4
- package/src/saves/storage.ts +10 -8
- package/src/settings.ts +5 -1
- package/src/store.ts +8 -4
- package/src/styles.css +5 -0
- package/src/widgets/widget-def.ts +15 -3
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
package/src/saves/storage.ts
CHANGED
|
@@ -410,8 +410,10 @@ function createIDBBackend(): StorageBackend {
|
|
|
410
410
|
let _backend: StorageBackend | null = null;
|
|
411
411
|
|
|
412
412
|
async function detectBackend(): Promise<StorageBackend> {
|
|
413
|
-
|
|
414
|
-
|
|
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
|
-
|
|
431
|
-
|
|
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
|
-
|
|
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):
|
|
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
|
-
|
|
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(
|
|
56
|
-
if (
|
|
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;
|