@rohal12/spindle 0.59.6 → 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 +197 -71
- package/dist/pkg/story-variables.js +13 -2
- package/package.json +1 -1
- package/src/class-registry.ts +12 -0
- package/src/components/App.tsx +7 -9
- package/src/components/macros/Computed.tsx +3 -2
- package/src/components/macros/MenubarButtons.tsx +8 -5
- package/src/components/macros/PassageDisplay.tsx +18 -7
- package/src/components/macros/SaveManager.tsx +18 -7
- package/src/components/macros/detached-body.tsx +16 -3
- package/src/execute-mutation.ts +3 -3
- package/src/expression.ts +9 -21
- package/src/hooks/use-modal-focus.ts +15 -1
- package/src/index.tsx +22 -7
- package/src/quick-actions.ts +23 -0
- package/src/saves/save-manager.ts +23 -6
- package/src/saves/storage.ts +10 -8
- package/src/settings.ts +5 -1
- package/src/store.ts +8 -4
- package/src/structural.ts +11 -5
- package/src/styles.css +5 -0
- package/src/widgets/widget-def.ts +15 -3
|
@@ -10269,7 +10269,13 @@ function widgetDef({ name = "", parameters = "" }) {
|
|
|
10269
10269
|
function parseWidgetDef(rawArgs) {
|
|
10270
10270
|
return widgetDef(parseMacroArgs(rawArgs, WIDGET_PARAMETERS));
|
|
10271
10271
|
}
|
|
10272
|
-
|
|
10272
|
+
/**
|
|
10273
|
+
* A definition: its arguments (read like a {widget} macro's, see
|
|
10274
|
+
* parseWidgetDef) and its body. Macro names are not case-sensitive.
|
|
10275
|
+
*/
|
|
10276
|
+
var WIDGET_DEFINITION = /\{widget\s+([^}]*)\}([\s\S]*?)\{\/widget\}/gi;
|
|
10277
|
+
/** `{@children}`, also with selectors such as `{.highlight @children}`. */
|
|
10278
|
+
var CHILDREN_PLACEHOLDER = /\{[^{}]*@children\s*\}/;
|
|
10273
10279
|
/**
|
|
10274
10280
|
* The names of the block widgets (those whose body renders `{@children}`)
|
|
10275
10281
|
* that StoryInit and the passages tagged `widget` define. They must be
|
|
@@ -10280,7 +10286,12 @@ function blockWidgetNames(passages) {
|
|
|
10280
10286
|
const names = [];
|
|
10281
10287
|
for (const passage of passages) {
|
|
10282
10288
|
if (passage.name !== "StoryInit" && !passage.tags?.includes("widget")) continue;
|
|
10283
|
-
for (const match of passage.content.matchAll(
|
|
10289
|
+
for (const match of passage.content.matchAll(WIDGET_DEFINITION)) {
|
|
10290
|
+
if (!CHILDREN_PLACEHOLDER.test(match[2])) continue;
|
|
10291
|
+
try {
|
|
10292
|
+
names.push(parseWidgetDef(match[1]).name);
|
|
10293
|
+
} catch {}
|
|
10294
|
+
}
|
|
10284
10295
|
}
|
|
10285
10296
|
return names;
|
|
10286
10297
|
}
|
package/package.json
CHANGED
package/src/class-registry.ts
CHANGED
|
@@ -110,11 +110,22 @@ const defineData = (target: object, key: string, value: unknown): void => {
|
|
|
110
110
|
});
|
|
111
111
|
};
|
|
112
112
|
|
|
113
|
+
/**
|
|
114
|
+
* A reducer extracts string keys, so devalue never sees the symbol keys of
|
|
115
|
+
* the original object: refuse them here, as it does for a plain object.
|
|
116
|
+
*/
|
|
117
|
+
function refuseSymbolKeys(value: object): void {
|
|
118
|
+
if (Object.getOwnPropertySymbols(value).length > 0) {
|
|
119
|
+
throw new TypeError('spindle: Cannot save an object with symbol keys');
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
113
123
|
/**
|
|
114
124
|
* Own enumerable keys of `value`, as a plain object; for an error also its
|
|
115
125
|
* message, cause and (AggregateError) errors, which are not enumerable.
|
|
116
126
|
*/
|
|
117
127
|
function ownData(value: object): Record<string, unknown> {
|
|
128
|
+
refuseSymbolKeys(value);
|
|
118
129
|
const data: Record<string, unknown> = {};
|
|
119
130
|
if (value instanceof Error) {
|
|
120
131
|
for (const key of ERROR_HIDDEN_KEYS) {
|
|
@@ -163,6 +174,7 @@ function reducers(): Record<string, (value: unknown) => unknown> {
|
|
|
163
174
|
}
|
|
164
175
|
const keys = Object.keys(v);
|
|
165
176
|
if (!keys.some(isEscapedOneCharKey)) return false;
|
|
177
|
+
refuseSymbolKeys(v);
|
|
166
178
|
if (keys.includes('__proto__')) {
|
|
167
179
|
throw new TypeError(
|
|
168
180
|
'spindle: Cannot save a property named "__proto__"',
|
package/src/components/App.tsx
CHANGED
|
@@ -5,6 +5,7 @@ import { NobrContext } from '../markup/render';
|
|
|
5
5
|
import { StoryInterface } from './StoryInterface';
|
|
6
6
|
import { TriggerDialogHost } from './TriggerDialogHost';
|
|
7
7
|
import { RuntimeErrors } from './RuntimeErrors';
|
|
8
|
+
import { quickLoad, quickSave } from '../quick-actions';
|
|
8
9
|
|
|
9
10
|
export function App() {
|
|
10
11
|
const { storyData, currentPassage, nobr } = useStoryFields(
|
|
@@ -15,14 +16,13 @@ export function App() {
|
|
|
15
16
|
|
|
16
17
|
useEffect(() => {
|
|
17
18
|
const onKeyDown = (e: KeyboardEvent) => {
|
|
18
|
-
const { quickSaveKey, quickLoadKey
|
|
19
|
-
useStoryStore.getState();
|
|
19
|
+
const { quickSaveKey, quickLoadKey } = useStoryStore.getState();
|
|
20
20
|
if (quickSaveKey !== null && e.key === quickSaveKey) {
|
|
21
21
|
e.preventDefault();
|
|
22
|
-
|
|
22
|
+
quickSave();
|
|
23
23
|
} else if (quickLoadKey !== null && e.key === quickLoadKey) {
|
|
24
24
|
e.preventDefault();
|
|
25
|
-
|
|
25
|
+
quickLoad();
|
|
26
26
|
}
|
|
27
27
|
};
|
|
28
28
|
document.addEventListener('keydown', onKeyDown);
|
|
@@ -41,9 +41,7 @@ export function App() {
|
|
|
41
41
|
</>
|
|
42
42
|
);
|
|
43
43
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
content
|
|
48
|
-
);
|
|
44
|
+
// One provider whatever `nobr` is: a changing tree shape would remount the
|
|
45
|
+
// passage and run its mount-only macros again
|
|
46
|
+
return <NobrContext.Provider value={nobr}>{content}</NobrContext.Provider>;
|
|
49
47
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useStoryStore } from '../../store';
|
|
2
2
|
import { evaluate } from '../../expression';
|
|
3
3
|
import { readState } from '../../execute-mutation';
|
|
4
|
-
import {
|
|
4
|
+
import { deepEqualStrict } from '../../structural';
|
|
5
5
|
import { defineMacro } from '../../define-macro';
|
|
6
6
|
import { MacroError, logMacroError } from './MacroError';
|
|
7
7
|
import { currentSourceLocation } from '../../utils/source-location';
|
|
@@ -67,7 +67,8 @@ function computeAndApply(
|
|
|
67
67
|
return;
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
|
|
70
|
+
// Strict: a change of only the aliases inside the result is a change
|
|
71
|
+
if (!deepEqualStrict(prevRef.current, newValue)) {
|
|
71
72
|
prevRef.current = newValue;
|
|
72
73
|
if (isLocal) {
|
|
73
74
|
try {
|
|
@@ -2,6 +2,7 @@ import { useState } from 'preact/hooks';
|
|
|
2
2
|
import { useStoryStore } from '../../store';
|
|
3
3
|
import { useStoryFields } from '../../hooks/use-story-fields';
|
|
4
4
|
import { settings } from '../../settings';
|
|
5
|
+
import { quickLoad, quickSave } from '../../quick-actions';
|
|
5
6
|
import { defineMacro } from '../../define-macro';
|
|
6
7
|
import { PassageDialog } from '../PassageDialog';
|
|
7
8
|
import type { ActionType } from '../../action-registry';
|
|
@@ -110,8 +111,11 @@ defineMenubarAction({
|
|
|
110
111
|
label: 'QuickSave',
|
|
111
112
|
actionType: 'save',
|
|
112
113
|
setup: () => {
|
|
113
|
-
const {
|
|
114
|
-
return {
|
|
114
|
+
const { quickSaveKey } = useStoryFields('quickSaveKey');
|
|
115
|
+
return {
|
|
116
|
+
perform: quickSave,
|
|
117
|
+
title: hotkeyTitle('Quick Save', quickSaveKey),
|
|
118
|
+
};
|
|
115
119
|
},
|
|
116
120
|
});
|
|
117
121
|
|
|
@@ -122,14 +126,13 @@ defineMenubarAction({
|
|
|
122
126
|
confirm: 'Load saved game? Current progress will be lost.',
|
|
123
127
|
setup: () => {
|
|
124
128
|
// knownSaves: re-render when the saves change, as hasSave() reads them
|
|
125
|
-
const {
|
|
126
|
-
'load',
|
|
129
|
+
const { hasSave, quickLoadKey } = useStoryFields(
|
|
127
130
|
'hasSave',
|
|
128
131
|
'quickLoadKey',
|
|
129
132
|
'knownSaves',
|
|
130
133
|
);
|
|
131
134
|
return {
|
|
132
|
-
perform:
|
|
135
|
+
perform: quickLoad,
|
|
133
136
|
disabled: !hasSave(),
|
|
134
137
|
title: hotkeyTitle('Quick Load', quickLoadKey),
|
|
135
138
|
};
|
|
@@ -80,6 +80,18 @@ function cleanupSnapshots(containerEl: Element | null): void {
|
|
|
80
80
|
}
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
/**
|
|
84
|
+
* `config` for players who prefer reduced motion: the CSS shortens the
|
|
85
|
+
* animations (see styles.css), so the waits that follow them shrink too,
|
|
86
|
+
* leaving no empty passage area before the next passage.
|
|
87
|
+
*/
|
|
88
|
+
function reduceMotion(config: ResolvedTransition): ResolvedTransition {
|
|
89
|
+
if (!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) {
|
|
90
|
+
return config;
|
|
91
|
+
}
|
|
92
|
+
return { ...config, duration: Math.min(config.duration, 10), pause: 0 };
|
|
93
|
+
}
|
|
94
|
+
|
|
83
95
|
defineMacro({
|
|
84
96
|
name: 'passage',
|
|
85
97
|
interpolate: true,
|
|
@@ -131,9 +143,6 @@ defineMacro({
|
|
|
131
143
|
const store = useStoryStore.getState();
|
|
132
144
|
const { history, historyIndex, transitionConfig } = store;
|
|
133
145
|
|
|
134
|
-
// Always consume the next transition (one-shot), regardless of what we do
|
|
135
|
-
const next = store.consumeNextTransition();
|
|
136
|
-
|
|
137
146
|
// Determine if this is a first load, restart, or save-load
|
|
138
147
|
const prevLen = prevHistoryLenRef.current;
|
|
139
148
|
prevHistoryLenRef.current = history.length;
|
|
@@ -152,6 +161,10 @@ defineMacro({
|
|
|
152
161
|
return;
|
|
153
162
|
}
|
|
154
163
|
|
|
164
|
+
// One-shot, used up by the first navigation, whatever we do with it
|
|
165
|
+
// (not by the initial render, which no navigation caused)
|
|
166
|
+
const next = store.consumeNextTransition();
|
|
167
|
+
|
|
155
168
|
// Cancel any in-progress transition
|
|
156
169
|
cancelTransition();
|
|
157
170
|
|
|
@@ -172,10 +185,8 @@ defineMacro({
|
|
|
172
185
|
}
|
|
173
186
|
|
|
174
187
|
// Resolve transition from tags → nextTransition → store default → built-in
|
|
175
|
-
const config =
|
|
176
|
-
targetPassage.tags,
|
|
177
|
-
next,
|
|
178
|
-
transitionConfig,
|
|
188
|
+
const config = reduceMotion(
|
|
189
|
+
resolveTransition(targetPassage.tags, next, transitionConfig),
|
|
179
190
|
);
|
|
180
191
|
|
|
181
192
|
resolvedTypeRef.current = config.type;
|
|
@@ -56,6 +56,7 @@ export function SaveManagerContent() {
|
|
|
56
56
|
} | null>(null);
|
|
57
57
|
const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set());
|
|
58
58
|
const [renamingId, setRenamingId] = useState<string | null>(null);
|
|
59
|
+
const renameCancelled = useRef(false);
|
|
59
60
|
const [renameValue, setRenameValue] = useState('');
|
|
60
61
|
const renameInputRef = useRef<HTMLInputElement>(null);
|
|
61
62
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
@@ -127,9 +128,8 @@ export function SaveManagerContent() {
|
|
|
127
128
|
const playthrough = resolvePlaythroughId();
|
|
128
129
|
try {
|
|
129
130
|
await saveWithHooks(undefined, undefined, beginSave, async (payload) => {
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
return createSave(ifid, ptId, payload);
|
|
131
|
+
// Queued now, in call order with other storage operations
|
|
132
|
+
return createSave(ifid, playthrough, payload);
|
|
133
133
|
});
|
|
134
134
|
showStatus('Save created');
|
|
135
135
|
await refresh();
|
|
@@ -143,8 +143,7 @@ export function SaveManagerContent() {
|
|
|
143
143
|
try {
|
|
144
144
|
await saveWithHooks(undefined, undefined, beginSave, async (payload) => {
|
|
145
145
|
// Only the current playthrough's saves offer "Save Here"
|
|
146
|
-
|
|
147
|
-
if (!(await overwriteSave(saveId, payload, undefined, ptId))) {
|
|
146
|
+
if (!(await overwriteSave(saveId, payload, undefined, playthrough))) {
|
|
148
147
|
throw new Error('Save not found');
|
|
149
148
|
}
|
|
150
149
|
});
|
|
@@ -193,6 +192,7 @@ export function SaveManagerContent() {
|
|
|
193
192
|
};
|
|
194
193
|
|
|
195
194
|
const handleRenameStart = (save: SaveRecord) => {
|
|
195
|
+
renameCancelled.current = false;
|
|
196
196
|
setRenamingId(save.meta.id);
|
|
197
197
|
setRenameValue(save.meta.title);
|
|
198
198
|
};
|
|
@@ -211,7 +211,18 @@ export function SaveManagerContent() {
|
|
|
211
211
|
|
|
212
212
|
const handleRenameKeyDown = (e: KeyboardEvent) => {
|
|
213
213
|
if (e.key === 'Enter') handleRenameConfirm();
|
|
214
|
-
else if (e.key === 'Escape')
|
|
214
|
+
else if (e.key === 'Escape') {
|
|
215
|
+
// Cancel the edit only: the dialog stays open, and the blur that
|
|
216
|
+
// follows removing the input must not confirm it
|
|
217
|
+
e.preventDefault();
|
|
218
|
+
e.stopPropagation();
|
|
219
|
+
renameCancelled.current = true;
|
|
220
|
+
setRenamingId(null);
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
const handleRenameBlur = () => {
|
|
225
|
+
if (!renameCancelled.current) handleRenameConfirm();
|
|
215
226
|
};
|
|
216
227
|
|
|
217
228
|
const handleExport = async (saveId: string) => {
|
|
@@ -352,7 +363,7 @@ export function SaveManagerContent() {
|
|
|
352
363
|
)
|
|
353
364
|
}
|
|
354
365
|
onKeyDown={handleRenameKeyDown}
|
|
355
|
-
onBlur={
|
|
366
|
+
onBlur={handleRenameBlur}
|
|
356
367
|
/>
|
|
357
368
|
) : (
|
|
358
369
|
<div class="save-slot-title">{save.meta.title}</div>
|
|
@@ -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/execute-mutation.ts
CHANGED
|
@@ -381,9 +381,9 @@ function relink(
|
|
|
381
381
|
function cloneNamespaces(from: VariableNamespaces): VariableNamespaces {
|
|
382
382
|
const seen = new Map<object, object>();
|
|
383
383
|
return {
|
|
384
|
-
variables: deepClone(from.variables, { seen }),
|
|
385
|
-
temporary: deepClone(from.temporary, { seen }),
|
|
386
|
-
transient: deepClone(from.transient, { seen }),
|
|
384
|
+
variables: deepClone(from.variables, { keepUnregistered: true, seen }),
|
|
385
|
+
temporary: deepClone(from.temporary, { keepUnregistered: true, seen }),
|
|
386
|
+
transient: deepClone(from.transient, { keepUnregistered: true, seen }),
|
|
387
387
|
};
|
|
388
388
|
}
|
|
389
389
|
|
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
|
|
package/src/index.tsx
CHANGED
|
@@ -62,6 +62,21 @@ function stopWithErrors(errors: string[]): never {
|
|
|
62
62
|
);
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
/**
|
|
66
|
+
* The undeclared variables the passages refer to, with the macros known now:
|
|
67
|
+
* the input macros that bind a variable (registered by author JS or
|
|
68
|
+
* StoryInit) and the roles of their parameters decide what is a reference.
|
|
69
|
+
*/
|
|
70
|
+
function variableErrors(
|
|
71
|
+
storyData: StoryData,
|
|
72
|
+
schema: Parameters<typeof validatePassages>[1],
|
|
73
|
+
): string[] {
|
|
74
|
+
const storeVarMacros = getMacroRegistry()
|
|
75
|
+
.filter((m) => m.storeVar)
|
|
76
|
+
.map((m) => m.name);
|
|
77
|
+
return validatePassages(storyData.passages, schema, storeVarMacros);
|
|
78
|
+
}
|
|
79
|
+
|
|
65
80
|
/**
|
|
66
81
|
* The markup errors (malformed markup, unknown macros, syntax errors in
|
|
67
82
|
* code) of the passages for which `only` holds, with the macros and widgets
|
|
@@ -134,10 +149,6 @@ export function boot() {
|
|
|
134
149
|
}
|
|
135
150
|
|
|
136
151
|
const schema = parseStoryVariables(storyVarsPassage.content);
|
|
137
|
-
// Include input macros registered by author JS, which ran above.
|
|
138
|
-
const storeVarMacros = getMacroRegistry()
|
|
139
|
-
.filter((m) => m.storeVar)
|
|
140
|
-
.map((m) => m.name);
|
|
141
152
|
// Pass 1: Register the block widgets as block macros BEFORE any passage
|
|
142
153
|
// is parsed (validation and StoryInit included), so that passages
|
|
143
154
|
// invoking block widgets and widget bodies using other block widgets
|
|
@@ -146,7 +157,7 @@ export function boot() {
|
|
|
146
157
|
registerBlockMacro(name);
|
|
147
158
|
}
|
|
148
159
|
|
|
149
|
-
const errors =
|
|
160
|
+
const errors = variableErrors(storyData, schema);
|
|
150
161
|
// StoryInit's markup must be valid before it runs; the other passages are
|
|
151
162
|
// validated once it has run, as it may define macros (see below).
|
|
152
163
|
errors.push(...markupErrors(storyData, (p) => p.name === 'StoryInit'));
|
|
@@ -186,8 +197,12 @@ export function boot() {
|
|
|
186
197
|
// storyinit after all state is settled (defaults + StoryInit + session)
|
|
187
198
|
initializeStory(loadSession(storyData.ifid));
|
|
188
199
|
|
|
189
|
-
// Every other passage's markup, now that StoryInit may have defined macros
|
|
190
|
-
|
|
200
|
+
// Every other passage's markup, now that StoryInit may have defined macros;
|
|
201
|
+
// so are the variable references, which macros' parameters decide
|
|
202
|
+
const markup = [
|
|
203
|
+
...variableErrors(storyData, schema),
|
|
204
|
+
...markupErrors(storyData, (p) => p.name !== 'StoryInit'),
|
|
205
|
+
];
|
|
191
206
|
if (markup.length > 0) stopWithErrors(markup);
|
|
192
207
|
|
|
193
208
|
// Pass 2: Full parse and register widgets from passages tagged "widget"
|
|
@@ -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
|
+
}
|
|
@@ -291,10 +291,12 @@ export const createSave = queued(createSaveNow);
|
|
|
291
291
|
|
|
292
292
|
async function createSaveNow(
|
|
293
293
|
ifid: string,
|
|
294
|
-
|
|
294
|
+
playthroughPromise: string | PromiseLike<string>,
|
|
295
295
|
payload: SavePayload,
|
|
296
296
|
custom: Record<string, unknown> = {},
|
|
297
297
|
): Promise<SaveRecord> {
|
|
298
|
+
const playthroughId = await playthroughPromise;
|
|
299
|
+
if (!playthroughId) throw new Error('No playthrough');
|
|
298
300
|
const now = new Date().toISOString();
|
|
299
301
|
const meta: SaveMeta = {
|
|
300
302
|
id: randomUUID(),
|
|
@@ -313,6 +315,10 @@ async function createSaveNow(
|
|
|
313
315
|
}
|
|
314
316
|
|
|
315
317
|
/**
|
|
318
|
+
* A playthrough passed as a promise (see resolvePlaythroughId) is awaited
|
|
319
|
+
* inside the operation, so the call takes its place in the order of
|
|
320
|
+
* operations when it is made.
|
|
321
|
+
*
|
|
316
322
|
* Replace the payload of an existing save, keeping its ID and `createdAt`.
|
|
317
323
|
* Pass the current `playthroughId` when the new payload comes from the
|
|
318
324
|
* running game: the save then holds that playthrough's state, so it is
|
|
@@ -333,8 +339,9 @@ async function overwriteSaveNow(
|
|
|
333
339
|
saveId: string,
|
|
334
340
|
payload: SavePayload,
|
|
335
341
|
custom?: Record<string, unknown>,
|
|
336
|
-
|
|
342
|
+
playthroughPromise?: string | PromiseLike<string>,
|
|
337
343
|
): Promise<SaveRecord | undefined> {
|
|
344
|
+
const playthroughId = await playthroughPromise;
|
|
338
345
|
const backend = await getBackend();
|
|
339
346
|
const existing = await backend.getSave(saveId);
|
|
340
347
|
if (!existing) return undefined;
|
|
@@ -706,14 +713,22 @@ const SESSION_KEY_PREFIX = 'spindle.session.';
|
|
|
706
713
|
* encodePayload). Throws, like a save, when the state holds a value a save
|
|
707
714
|
* cannot (a function, an instance of an unregistered class, a unique
|
|
708
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.
|
|
709
720
|
*/
|
|
710
|
-
export function saveSession(
|
|
721
|
+
export function saveSession(
|
|
722
|
+
ifid: string,
|
|
723
|
+
payload: SavePayload,
|
|
724
|
+
): { error: unknown } | undefined {
|
|
711
725
|
const text = JSON.stringify(encodePayload(payload));
|
|
712
726
|
try {
|
|
713
727
|
sessionStorage.setItem(`${SESSION_KEY_PREFIX}${ifid}`, text);
|
|
714
|
-
} catch {
|
|
715
|
-
|
|
728
|
+
} catch (error) {
|
|
729
|
+
return { error };
|
|
716
730
|
}
|
|
731
|
+
return undefined;
|
|
717
732
|
}
|
|
718
733
|
|
|
719
734
|
/**
|
|
@@ -882,13 +897,15 @@ async function clearAllDataNow(): Promise<void> {
|
|
|
882
897
|
resetBackend();
|
|
883
898
|
|
|
884
899
|
// Clear all Spindle session keys from sessionStorage
|
|
885
|
-
|
|
900
|
+
try {
|
|
886
901
|
const toRemove: string[] = [];
|
|
887
902
|
for (let i = 0; i < sessionStorage.length; i++) {
|
|
888
903
|
const key = sessionStorage.key(i);
|
|
889
904
|
if (key?.startsWith('spindle.session.')) toRemove.push(key);
|
|
890
905
|
}
|
|
891
906
|
for (const key of toRemove) sessionStorage.removeItem(key);
|
|
907
|
+
} catch {
|
|
908
|
+
// no sessionStorage (or access denied): nothing to clear
|
|
892
909
|
}
|
|
893
910
|
}
|
|
894
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 {
|