@rohal12/spindle 0.51.4 → 0.52.1
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 +4512 -1702
- package/dist/pkg/macro-registry.json +7 -7
- package/dist/pkg/story-variables.js +1636 -177
- package/package.json +5 -2
- package/src/action-registry.ts +70 -18
- package/src/automation/runner.ts +2 -1
- package/src/class-registry.ts +214 -103
- package/src/components/Passage.tsx +2 -2
- package/src/components/PassageDialog.tsx +2 -5
- package/src/components/StoryInterface.tsx +2 -4
- package/src/components/macros/Button.tsx +5 -31
- package/src/components/macros/Checkbox.tsx +7 -4
- package/src/components/macros/Computed.tsx +28 -14
- package/src/components/macros/For.tsx +29 -3
- package/src/components/macros/If.tsx +8 -0
- package/src/components/macros/Include.tsx +7 -6
- package/src/components/macros/MacroError.tsx +2 -1
- package/src/components/macros/MacroLink.tsx +12 -45
- package/src/components/macros/Meter.tsx +11 -3
- package/src/components/macros/Nobr.tsx +1 -0
- package/src/components/macros/PassageDisplay.tsx +3 -0
- package/src/components/macros/Print.tsx +4 -0
- package/src/components/macros/Radiobutton.tsx +5 -2
- package/src/components/macros/SaveManager.tsx +25 -8
- package/src/components/macros/Set.tsx +5 -4
- package/src/components/macros/Span.tsx +1 -0
- package/src/components/macros/StoryTitle.tsx +1 -0
- package/src/components/macros/Switch.tsx +13 -0
- package/src/components/macros/Unset.tsx +31 -10
- package/src/components/macros/VarDisplay.tsx +21 -4
- package/src/components/macros/Widget.tsx +20 -1
- package/src/components/macros/WidgetInvocation.tsx +17 -75
- package/src/components/macros/arg-utils.ts +107 -1
- package/src/components/macros/detached-body.tsx +68 -0
- package/src/components/macros/option-utils.ts +3 -2
- package/src/define-macro.ts +32 -5
- package/src/execute-mutation.ts +271 -68
- package/src/expression.ts +91 -59
- package/src/hooks/use-action.ts +24 -3
- package/src/hooks/use-interpolate.ts +36 -5
- package/src/index.tsx +12 -2
- package/src/interpolation.ts +394 -96
- package/src/js-lexer.ts +1231 -97
- package/src/markup/ast.ts +7 -2
- package/src/markup/code-attributes.ts +64 -0
- package/src/markup/markdown.ts +188 -9
- package/src/markup/render.tsx +430 -49
- package/src/markup/tokenizer.ts +578 -110
- package/src/prng.ts +41 -8
- package/src/registry.ts +35 -0
- package/src/saves/save-manager.ts +346 -160
- package/src/saves/storage.ts +16 -7
- package/src/saves/types.ts +2 -1
- package/src/settings.ts +12 -9
- package/src/store.ts +640 -186
- package/src/story-api.ts +38 -73
- package/src/story-init.ts +1 -1
- package/src/story-variables.ts +98 -102
- package/src/triggers.ts +10 -12
- package/src/utils/counts.ts +45 -0
- package/src/utils/error-message.ts +12 -0
- package/src/utils/live-locals.ts +10 -3
- package/src/utils/namespace.ts +71 -0
- package/src/utils/object-path.ts +99 -14
- package/src/utils/stable-key.ts +14 -9
- package/src/widgets/widget-registry.ts +9 -0
- package/types/index.d.ts +43 -7
- package/types/tooling.d.ts +1 -0
|
@@ -6,6 +6,7 @@ import { renderNodes, NobrContext } from '../markup/render';
|
|
|
6
6
|
import { useStoryStore } from '../store';
|
|
7
7
|
import { emitFromRender } from '../event-emitter';
|
|
8
8
|
import { useModalFocus } from '../hooks/use-modal-focus';
|
|
9
|
+
import { errorMessage } from '../utils/error-message';
|
|
9
10
|
|
|
10
11
|
export const DialogCloseContext = createContext<(() => void) | null>(null);
|
|
11
12
|
|
|
@@ -61,11 +62,7 @@ export function PassageDialog({
|
|
|
61
62
|
nodes
|
|
62
63
|
);
|
|
63
64
|
} catch (err) {
|
|
64
|
-
return (
|
|
65
|
-
<div class="error">
|
|
66
|
-
Error in dialog: {err instanceof Error ? err.message : String(err)}
|
|
67
|
-
</div>
|
|
68
|
-
);
|
|
65
|
+
return <div class="error">Error in dialog: {errorMessage(err)}</div>;
|
|
69
66
|
}
|
|
70
67
|
}, [markup, nobr]);
|
|
71
68
|
|
|
@@ -3,6 +3,7 @@ import { useStoryStore } from '../store';
|
|
|
3
3
|
import { tokenize } from '../markup/tokenizer';
|
|
4
4
|
import { buildAST } from '../markup/ast';
|
|
5
5
|
import { renderInlineNodes, NobrContext } from '../markup/render';
|
|
6
|
+
import { errorMessage } from '../utils/error-message';
|
|
6
7
|
|
|
7
8
|
const DEFAULT_MARKUP =
|
|
8
9
|
'<header class="story-menubar">{story-title}{back}{forward}{restart}{quicksave}{quickload}{saves}{settings}</header>\n{passage}';
|
|
@@ -22,10 +23,7 @@ export function StoryInterface() {
|
|
|
22
23
|
return <>{renderInlineNodes(ast)}</>;
|
|
23
24
|
} catch (err) {
|
|
24
25
|
return (
|
|
25
|
-
<span class="error">
|
|
26
|
-
Error in StoryInterface:{' '}
|
|
27
|
-
{err instanceof Error ? err.message : String(err)}
|
|
28
|
-
</span>
|
|
26
|
+
<span class="error">Error in StoryInterface: {errorMessage(err)}</span>
|
|
29
27
|
);
|
|
30
28
|
}
|
|
31
29
|
}, [markup]);
|
|
@@ -1,14 +1,6 @@
|
|
|
1
|
-
import { h, render } from 'preact';
|
|
2
|
-
import { useContext } from 'preact/hooks';
|
|
3
1
|
import { defineMacro } from '../../define-macro';
|
|
4
2
|
import { readWholeQuoted } from './arg-utils';
|
|
5
|
-
import {
|
|
6
|
-
renderNodes,
|
|
7
|
-
LocalsUpdateContext,
|
|
8
|
-
LocalsValuesContext,
|
|
9
|
-
NobrContext,
|
|
10
|
-
} from '../../markup/render';
|
|
11
|
-
import { liveLocalsView } from '../../utils/live-locals';
|
|
3
|
+
import { useDetachedBody } from './detached-body';
|
|
12
4
|
|
|
13
5
|
defineMacro({
|
|
14
6
|
name: 'button',
|
|
@@ -18,29 +10,11 @@ defineMacro({
|
|
|
18
10
|
const text =
|
|
19
11
|
readWholeQuoted(rawArgs.trim()) ?? rawArgs.replace(/^["']|["']$/g, '');
|
|
20
12
|
const label = ctx.resolve?.(text) ?? rawArgs;
|
|
21
|
-
const
|
|
13
|
+
const runBody = useDetachedBody();
|
|
22
14
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
// Wrap with locals context so @local variables from for-loops are available.
|
|
27
|
-
const container = document.createElement('div');
|
|
28
|
-
const vnode = h(
|
|
29
|
-
NobrContext.Provider,
|
|
30
|
-
{ value: nobr },
|
|
31
|
-
h(
|
|
32
|
-
LocalsUpdateContext.Provider,
|
|
33
|
-
{ value: { update: ctx.update, getValues: ctx.getValues } },
|
|
34
|
-
h(
|
|
35
|
-
LocalsValuesContext.Provider,
|
|
36
|
-
{ value: liveLocalsView(ctx.getValues) },
|
|
37
|
-
renderNodes(children),
|
|
38
|
-
),
|
|
39
|
-
),
|
|
40
|
-
);
|
|
41
|
-
render(vnode, container);
|
|
42
|
-
render(null, container);
|
|
43
|
-
};
|
|
15
|
+
// Run the body outside the passage tree: all macro side effects ({set},
|
|
16
|
+
// {if}, {unwatch}, etc.) fire through the normal Preact pipeline.
|
|
17
|
+
const handleClick = () => runBody(children);
|
|
44
18
|
|
|
45
19
|
ctx.useAction({
|
|
46
20
|
type: 'button',
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { defineMacro } from '../../define-macro';
|
|
2
2
|
import { readWholeQuoted, stripLooseQuotes } from './arg-utils';
|
|
3
3
|
|
|
4
|
-
function
|
|
5
|
-
|
|
4
|
+
export function parseCheckboxLabel(rawArgs: string): string {
|
|
5
|
+
// `s`: a quoted label may span lines. Its trailing whitespace is trimmed
|
|
6
|
+
// here, not by the regex: `(.+?)\s*$` tries every position of a run of
|
|
7
|
+
// spaces in the label, taking time quadratic in its length.
|
|
8
|
+
const match = rawArgs.match(/^\s*["']?\$?[\w.]+["']?\s+(.+)$/s);
|
|
6
9
|
if (!match) return '';
|
|
7
|
-
const rest = match[1]!;
|
|
10
|
+
const rest = match[1]!.trimEnd() || match[1]!;
|
|
8
11
|
return readWholeQuoted(rest) ?? stripLooseQuotes(rest);
|
|
9
12
|
}
|
|
10
13
|
|
|
@@ -12,7 +15,7 @@ defineMacro({
|
|
|
12
15
|
name: 'checkbox',
|
|
13
16
|
storeVar: true,
|
|
14
17
|
render({ rawArgs }, ctx) {
|
|
15
|
-
const label =
|
|
18
|
+
const label = parseCheckboxLabel(rawArgs);
|
|
16
19
|
|
|
17
20
|
ctx.useAction({
|
|
18
21
|
type: 'checkbox',
|
|
@@ -1,9 +1,18 @@
|
|
|
1
1
|
import { useStoryStore } from '../../store';
|
|
2
2
|
import { evaluate } from '../../expression';
|
|
3
|
+
import { readState } from '../../execute-mutation';
|
|
3
4
|
import { deepEqual } from '../../class-registry';
|
|
4
5
|
import { currentSourceLocation } from '../../utils/source-location';
|
|
5
6
|
import { defineMacro } from '../../define-macro';
|
|
6
7
|
import { MacroError } from './MacroError';
|
|
8
|
+
import { checkVariableName } from '../../utils/namespace';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The previous output before the first successful evaluation. Distinct from
|
|
12
|
+
* every value an expression can produce, so the first result is always
|
|
13
|
+
* applied, even `undefined`; a first evaluation that throws leaves it unset.
|
|
14
|
+
*/
|
|
15
|
+
const UNSET: unique symbol = Symbol('unset');
|
|
7
16
|
|
|
8
17
|
function parseComputedArgs(rawArgs: string): { target: string; expr: string } {
|
|
9
18
|
const trimmed = rawArgs.trim();
|
|
@@ -29,6 +38,7 @@ function parseComputedArgs(rawArgs: string): { target: string; expr: string } {
|
|
|
29
38
|
);
|
|
30
39
|
}
|
|
31
40
|
|
|
41
|
+
checkVariableName(target.slice(1), target);
|
|
32
42
|
return { target, expr };
|
|
33
43
|
}
|
|
34
44
|
}
|
|
@@ -38,22 +48,30 @@ function parseComputedArgs(rawArgs: string): { target: string; expr: string } {
|
|
|
38
48
|
);
|
|
39
49
|
}
|
|
40
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Evaluate `expr` against the current values — the store's state and the
|
|
53
|
+
* enclosing locals scope's live values (`getLocals`) — and write the result
|
|
54
|
+
* to the target if it differs from the previous output (always, the first
|
|
55
|
+
* time). Reading live values rather than the render's snapshot means the
|
|
56
|
+
* first computation sees a local a preceding {set} just assigned (the scope's
|
|
57
|
+
* context value only catches up on its re-render), and the first computation
|
|
58
|
+
* and later recomputations read the same source.
|
|
59
|
+
*/
|
|
41
60
|
function computeAndApply(
|
|
42
61
|
expr: string,
|
|
43
62
|
name: string,
|
|
44
63
|
isTemp: boolean,
|
|
45
64
|
isLocal: boolean,
|
|
46
|
-
|
|
47
|
-
temporary: Record<string, unknown>,
|
|
48
|
-
locals: Record<string, unknown>,
|
|
49
|
-
transient: Record<string, unknown>,
|
|
65
|
+
getLocals: () => Record<string, unknown>,
|
|
50
66
|
rawArgs: string,
|
|
51
67
|
prevRef: { current: unknown },
|
|
52
68
|
localsUpdate: ((key: string, value: unknown) => void) | null,
|
|
53
69
|
): void {
|
|
54
70
|
let newValue: unknown;
|
|
55
71
|
try {
|
|
56
|
-
|
|
72
|
+
// In program order, also when mutation code sets this off
|
|
73
|
+
const { variables, temporary, transient } = readState();
|
|
74
|
+
newValue = evaluate(expr, variables, temporary, getLocals(), transient);
|
|
57
75
|
} catch (err) {
|
|
58
76
|
console.error(
|
|
59
77
|
`spindle: Error in {computed ${rawArgs}}${currentSourceLocation()}:`,
|
|
@@ -104,7 +122,7 @@ defineMacro({
|
|
|
104
122
|
const name = target.slice(1);
|
|
105
123
|
const localsUpdate = isLocal ? ctx.update : null;
|
|
106
124
|
|
|
107
|
-
const prevOutput = ctx.hooks.useRef<unknown>(
|
|
125
|
+
const prevOutput = ctx.hooks.useRef<unknown>(UNSET);
|
|
108
126
|
|
|
109
127
|
const ran = ctx.hooks.useRef(false);
|
|
110
128
|
if (!ran.current) {
|
|
@@ -114,26 +132,22 @@ defineMacro({
|
|
|
114
132
|
name,
|
|
115
133
|
isTemp,
|
|
116
134
|
isLocal,
|
|
117
|
-
|
|
118
|
-
mergedTemps,
|
|
119
|
-
mergedLocals,
|
|
120
|
-
mergedTrans,
|
|
135
|
+
ctx.getValues,
|
|
121
136
|
rawArgs,
|
|
122
137
|
prevOutput,
|
|
123
138
|
localsUpdate,
|
|
124
139
|
);
|
|
125
140
|
}
|
|
126
141
|
|
|
142
|
+
// The merged values only decide when to recompute; computeAndApply reads
|
|
143
|
+
// the current ones.
|
|
127
144
|
ctx.hooks.useLayoutEffect(() => {
|
|
128
145
|
computeAndApply(
|
|
129
146
|
expr,
|
|
130
147
|
name,
|
|
131
148
|
isTemp,
|
|
132
149
|
isLocal,
|
|
133
|
-
|
|
134
|
-
mergedTemps,
|
|
135
|
-
mergedLocals,
|
|
136
|
-
mergedTrans,
|
|
150
|
+
ctx.getValues,
|
|
137
151
|
rawArgs,
|
|
138
152
|
prevOutput,
|
|
139
153
|
localsUpdate,
|
|
@@ -10,12 +10,18 @@ import {
|
|
|
10
10
|
LocalsUpdateContext,
|
|
11
11
|
NobrContext,
|
|
12
12
|
InlineContext,
|
|
13
|
+
RawTextContext,
|
|
13
14
|
renderNodes,
|
|
14
15
|
} from '../../markup/render';
|
|
15
16
|
import { defineMacro } from '../../define-macro';
|
|
16
17
|
import { MacroError } from './MacroError';
|
|
17
18
|
import { stableKey } from '../../utils/stable-key';
|
|
18
19
|
import type { ASTNode } from '../../markup/ast';
|
|
20
|
+
import {
|
|
21
|
+
checkVariableName,
|
|
22
|
+
createNamespace,
|
|
23
|
+
withEntry,
|
|
24
|
+
} from '../../utils/namespace';
|
|
19
25
|
|
|
20
26
|
/**
|
|
21
27
|
* Parse for-loop args: "@item, @i of $list" or "@item of $list"
|
|
@@ -46,6 +52,9 @@ function parseForArgs(rawArgs: string): {
|
|
|
46
52
|
);
|
|
47
53
|
}
|
|
48
54
|
|
|
55
|
+
checkVariableName(itemVar.slice(1), itemVar);
|
|
56
|
+
if (indexVar) checkVariableName(indexVar.slice(1), indexVar);
|
|
57
|
+
|
|
49
58
|
return {
|
|
50
59
|
itemVar: itemVar.slice(1),
|
|
51
60
|
indexVar: indexVar ? indexVar.slice(1) : null,
|
|
@@ -81,7 +90,7 @@ function ForIteration({
|
|
|
81
90
|
);
|
|
82
91
|
|
|
83
92
|
const localState = useMemo(
|
|
84
|
-
() => (
|
|
93
|
+
() => createNamespace(parentValues, ownKeys, localMutations),
|
|
85
94
|
[parentValues, ownKeys, localMutations],
|
|
86
95
|
);
|
|
87
96
|
|
|
@@ -93,18 +102,20 @@ function ForIteration({
|
|
|
93
102
|
// Apply synchronously so later macros in the same render pass (e.g. a
|
|
94
103
|
// second {set}) read the new value via getValues(); the state update
|
|
95
104
|
// then re-renders consumers of LocalsValuesContext.
|
|
96
|
-
|
|
105
|
+
checkVariableName(key, `@${key}`);
|
|
106
|
+
valuesRef.current = withEntry(valuesRef.current, key, value);
|
|
97
107
|
setLocalMutations((prev) => ({ ...prev, [key]: value }));
|
|
98
108
|
}, []);
|
|
99
109
|
const updater = useMemo(() => ({ update, getValues }), [update, getValues]);
|
|
100
110
|
|
|
101
111
|
const nobr = useContext(NobrContext);
|
|
102
112
|
const inline = useContext(InlineContext);
|
|
113
|
+
const raw = useContext(RawTextContext);
|
|
103
114
|
|
|
104
115
|
return (
|
|
105
116
|
<LocalsUpdateContext.Provider value={updater}>
|
|
106
117
|
<LocalsValuesContext.Provider value={localState}>
|
|
107
|
-
{renderNodes(children, { nobr, inline, locals: localState })}
|
|
118
|
+
{renderNodes(children, { nobr, inline, raw, locals: localState })}
|
|
108
119
|
</LocalsValuesContext.Provider>
|
|
109
120
|
</LocalsUpdateContext.Provider>
|
|
110
121
|
);
|
|
@@ -166,4 +177,19 @@ defineMacro({
|
|
|
166
177
|
|
|
167
178
|
return ctx.wrap(content);
|
|
168
179
|
},
|
|
180
|
+
text({ rawArgs, children = [] }, ctx) {
|
|
181
|
+
const { itemVar, indexVar, listExpr } = parseForArgs(rawArgs);
|
|
182
|
+
const list = ctx.evaluate(listExpr);
|
|
183
|
+
if (!Array.isArray(list)) {
|
|
184
|
+
throw new Error('expression did not evaluate to an array');
|
|
185
|
+
}
|
|
186
|
+
return list
|
|
187
|
+
.map((item, i) =>
|
|
188
|
+
ctx.renderText(children, {
|
|
189
|
+
[itemVar]: item,
|
|
190
|
+
...(indexVar ? { [indexVar]: i } : undefined),
|
|
191
|
+
}),
|
|
192
|
+
)
|
|
193
|
+
.join('');
|
|
194
|
+
},
|
|
169
195
|
});
|
|
@@ -3,7 +3,7 @@ import { tokenize } from '../../markup/tokenizer';
|
|
|
3
3
|
import { buildAST } from '../../markup/ast';
|
|
4
4
|
import { NobrContext } from '../../markup/render';
|
|
5
5
|
import { defineMacro } from '../../define-macro';
|
|
6
|
-
import { isWhitespace, topLevelIndices } from './arg-utils';
|
|
6
|
+
import { endsWithOperator, isWhitespace, topLevelIndices } from './arg-utils';
|
|
7
7
|
|
|
8
8
|
const FLAG = 'inline';
|
|
9
9
|
|
|
@@ -13,7 +13,7 @@ const FLAG = 'inline';
|
|
|
13
13
|
* braces, separated by whitespace from the target expression, so passage
|
|
14
14
|
* names and expressions containing the word stay intact (#201).
|
|
15
15
|
*/
|
|
16
|
-
function parseIncludeArgs(rawArgs: string): {
|
|
16
|
+
export function parseIncludeArgs(rawArgs: string): {
|
|
17
17
|
nameExpr: string;
|
|
18
18
|
inline: boolean;
|
|
19
19
|
} {
|
|
@@ -32,19 +32,20 @@ function parseIncludeArgs(rawArgs: string): {
|
|
|
32
32
|
last = [spaces[e]!, spaces[spaces.length - 1]! + 1];
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
// Next to a binary operator the word is an operand (`"a" + inline`)
|
|
35
|
+
// Next to a binary operator the word is an operand (`"a" + inline`);
|
|
36
|
+
// the closing `/` of a regex literal is not one.
|
|
36
37
|
if (
|
|
37
38
|
last &&
|
|
38
39
|
last[1] === trimmed.length - FLAG.length &&
|
|
39
40
|
trimmed.endsWith(FLAG)
|
|
40
41
|
) {
|
|
41
42
|
const rest = trimmed.slice(0, last[0]);
|
|
42
|
-
if (
|
|
43
|
-
return { nameExpr: rest, inline: true };
|
|
43
|
+
if (!endsWithOperator(rest)) return { nameExpr: rest, inline: true };
|
|
44
44
|
}
|
|
45
45
|
if (first && first[0] === FLAG.length && trimmed.startsWith(FLAG)) {
|
|
46
46
|
const rest = trimmed.slice(first[1]);
|
|
47
|
-
|
|
47
|
+
// `inline %name` reads a transient variable; `inline % 2` is modulo.
|
|
48
|
+
if (!/^(?:[-+*/&|^=<>?:,.]|%(?![A-Za-z_]))/.test(rest))
|
|
48
49
|
return { nameExpr: rest, inline: true };
|
|
49
50
|
}
|
|
50
51
|
return { nameExpr: trimmed, inline: false };
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { currentSourceLocation } from '../../utils/source-location';
|
|
2
|
+
import { errorMessage } from '../../utils/error-message';
|
|
2
3
|
|
|
3
4
|
export function MacroError({
|
|
4
5
|
macro,
|
|
@@ -12,7 +13,7 @@ export function MacroError({
|
|
|
12
13
|
class="error"
|
|
13
14
|
title={String(error)}
|
|
14
15
|
>
|
|
15
|
-
{`{${macro} error${currentSourceLocation()}: ${
|
|
16
|
+
{`{${macro} error${currentSourceLocation()}: ${errorMessage(error)}}`}
|
|
16
17
|
</span>
|
|
17
18
|
);
|
|
18
19
|
}
|
|
@@ -1,17 +1,9 @@
|
|
|
1
|
-
import { h, render } from 'preact';
|
|
2
|
-
import { useContext } from 'preact/hooks';
|
|
3
1
|
import { useStoryStore } from '../../store';
|
|
4
|
-
import {
|
|
5
|
-
renderNodes,
|
|
6
|
-
LocalsUpdateContext,
|
|
7
|
-
LocalsValuesContext,
|
|
8
|
-
NobrContext,
|
|
9
|
-
} from '../../markup/render';
|
|
10
2
|
import { defineMacro } from '../../define-macro';
|
|
11
|
-
import { liveLocalsView } from '../../utils/live-locals';
|
|
12
3
|
import { unescapeQuoted } from './arg-utils';
|
|
4
|
+
import { useDetachedBody } from './detached-body';
|
|
13
5
|
|
|
14
|
-
function
|
|
6
|
+
export function parseLinkArgs(rawArgs: string): {
|
|
15
7
|
display: string;
|
|
16
8
|
passage: string | null;
|
|
17
9
|
} {
|
|
@@ -33,58 +25,33 @@ function parseArgs(rawArgs: string): {
|
|
|
33
25
|
return { display: rawArgs.trim(), passage: null };
|
|
34
26
|
}
|
|
35
27
|
|
|
36
|
-
function renderChildrenDetached(
|
|
37
|
-
children: import('../../markup/ast').ASTNode[],
|
|
38
|
-
getValues: () => Record<string, unknown>,
|
|
39
|
-
update: (key: string, value: unknown) => void,
|
|
40
|
-
nobr: boolean,
|
|
41
|
-
) {
|
|
42
|
-
const container = document.createElement('div');
|
|
43
|
-
const vnode = h(
|
|
44
|
-
NobrContext.Provider,
|
|
45
|
-
{ value: nobr },
|
|
46
|
-
h(
|
|
47
|
-
LocalsUpdateContext.Provider,
|
|
48
|
-
{ value: { update, getValues } },
|
|
49
|
-
h(
|
|
50
|
-
LocalsValuesContext.Provider,
|
|
51
|
-
{ value: liveLocalsView(getValues) },
|
|
52
|
-
renderNodes(children),
|
|
53
|
-
),
|
|
54
|
-
),
|
|
55
|
-
);
|
|
56
|
-
render(vnode, container);
|
|
57
|
-
render(null, container);
|
|
58
|
-
}
|
|
59
|
-
|
|
60
28
|
defineMacro({
|
|
61
29
|
name: 'link',
|
|
62
30
|
block: true,
|
|
63
31
|
interpolate: true,
|
|
64
32
|
render({ rawArgs, children = [] }, ctx) {
|
|
65
|
-
const { display, passage } =
|
|
66
|
-
const
|
|
33
|
+
const { display, passage } = parseLinkArgs(rawArgs);
|
|
34
|
+
const runBody = useDetachedBody();
|
|
67
35
|
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
renderChildrenDetached(children, ctx.getValues, ctx.update, nobr);
|
|
36
|
+
const perform = () => {
|
|
37
|
+
runBody(children);
|
|
71
38
|
if (passage) {
|
|
72
39
|
useStoryStore.getState().navigate(passage);
|
|
73
40
|
}
|
|
74
41
|
};
|
|
75
42
|
|
|
43
|
+
const handleClick = (e: Event) => {
|
|
44
|
+
e.preventDefault();
|
|
45
|
+
perform();
|
|
46
|
+
};
|
|
47
|
+
|
|
76
48
|
ctx.useAction({
|
|
77
49
|
type: 'link',
|
|
78
50
|
key: passage || display,
|
|
79
51
|
authorId: ctx.id,
|
|
80
52
|
label: display,
|
|
81
53
|
target: passage ?? undefined,
|
|
82
|
-
perform
|
|
83
|
-
renderChildrenDetached(children, ctx.getValues, ctx.update, nobr);
|
|
84
|
-
if (passage) {
|
|
85
|
-
useStoryStore.getState().navigate(passage);
|
|
86
|
-
}
|
|
87
|
-
},
|
|
54
|
+
perform,
|
|
88
55
|
});
|
|
89
56
|
|
|
90
57
|
return (
|
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
import { defineMacro } from '../../define-macro';
|
|
2
2
|
import { MacroError } from './MacroError';
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
endsWithOperator,
|
|
5
|
+
isWhitespace,
|
|
6
|
+
readWholeQuoted,
|
|
7
|
+
splitTopLevel,
|
|
8
|
+
} from './arg-utils';
|
|
4
9
|
|
|
5
10
|
/**
|
|
6
11
|
* Parse `{meter currentExpr maxExpr ["label"]}`. Arguments are separated by
|
|
7
12
|
* whitespace outside string and template literals and bracket pairs; a
|
|
8
13
|
* trailing standalone `"…"` or `'…'` string is the label mode, with `\"`,
|
|
9
|
-
* `\'` and `\\` escapes.
|
|
14
|
+
* `\'` and `\\` escapes. A string after an operator (`$a ?? "5"`) is an
|
|
15
|
+
* operand of the max expression, not the label.
|
|
10
16
|
*/
|
|
11
17
|
export function parseMeterArgs(rawArgs: string): {
|
|
12
18
|
currentExpr: string;
|
|
@@ -17,7 +23,9 @@ export function parseMeterArgs(rawArgs: string): {
|
|
|
17
23
|
|
|
18
24
|
let labelMode = '';
|
|
19
25
|
const label =
|
|
20
|
-
tokens.length >= 2
|
|
26
|
+
tokens.length >= 2 && !endsWithOperator(tokens.slice(0, -1).join(' '))
|
|
27
|
+
? readWholeQuoted(tokens[tokens.length - 1]!)
|
|
28
|
+
: null;
|
|
21
29
|
if (label !== null) {
|
|
22
30
|
labelMode = label;
|
|
23
31
|
tokens.pop();
|
|
@@ -105,6 +105,9 @@ defineMacro({
|
|
|
105
105
|
cleanupSnapshots(containerRef.current);
|
|
106
106
|
}, []);
|
|
107
107
|
|
|
108
|
+
// A transition still running on unmount must not fire into it.
|
|
109
|
+
useEffect(() => cancelTransition, [cancelTransition]);
|
|
110
|
+
|
|
108
111
|
// State machine effect — triggers on every navigation
|
|
109
112
|
useEffect(() => {
|
|
110
113
|
const store = useStoryStore.getState();
|
|
@@ -6,7 +6,10 @@ import {
|
|
|
6
6
|
stripLooseQuotes,
|
|
7
7
|
} from './arg-utils';
|
|
8
8
|
|
|
9
|
-
function parseRadioArgs(rawArgs: string): {
|
|
9
|
+
export function parseRadioArgs(rawArgs: string): {
|
|
10
|
+
value: string;
|
|
11
|
+
label: string;
|
|
12
|
+
} {
|
|
10
13
|
// $var "value" label: a quoted value accepts \" \' and \\ escapes and
|
|
11
14
|
// may contain the other quote kind.
|
|
12
15
|
const head = rawArgs.match(/^\s*["']?\$?[\w.]+["']?\s+/);
|
|
@@ -24,7 +27,7 @@ function parseRadioArgs(rawArgs: string): { value: string; label: string } {
|
|
|
24
27
|
}
|
|
25
28
|
|
|
26
29
|
const match = rawArgs.match(
|
|
27
|
-
/^\s*["']?\$?[\w.]+["']?\s+["'](.+?)["']\s+["']?(.+?)["']?\s
|
|
30
|
+
/^\s*["']?\$?[\w.]+["']?\s+["'](.+?)["']\s+["']?(.+?)["']?\s*$/s,
|
|
28
31
|
);
|
|
29
32
|
if (!match) {
|
|
30
33
|
const parts = rawArgs.trim().split(/\s+/).slice(1);
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
} from '../../saves/save-manager';
|
|
23
23
|
import { DialogCloseContext } from '../PassageDialog';
|
|
24
24
|
import { defineMacro } from '../../define-macro';
|
|
25
|
+
import { errorMessage } from '../../utils/error-message';
|
|
25
26
|
|
|
26
27
|
function relativeTime(iso: string): string {
|
|
27
28
|
const diff = Date.now() - new Date(iso).getTime();
|
|
@@ -84,15 +85,25 @@ export function SaveManagerContent() {
|
|
|
84
85
|
}
|
|
85
86
|
}, [renamingId]);
|
|
86
87
|
|
|
88
|
+
// Actions are async and may finish after the manager closed: once
|
|
89
|
+
// unmounted, no status is shown and no timer is left behind.
|
|
90
|
+
const mounted = useRef(true);
|
|
87
91
|
const statusTimer = useRef<number>();
|
|
92
|
+
const closeTimer = useRef<number>();
|
|
88
93
|
const showStatus = (text: string, type: 'success' | 'error' = 'success') => {
|
|
94
|
+
if (!mounted.current) return;
|
|
89
95
|
clearTimeout(statusTimer.current);
|
|
90
96
|
setStatus({ text, type });
|
|
91
97
|
statusTimer.current = window.setTimeout(() => setStatus(null), 3000);
|
|
92
98
|
};
|
|
93
99
|
|
|
94
100
|
useEffect(() => {
|
|
95
|
-
|
|
101
|
+
mounted.current = true;
|
|
102
|
+
return () => {
|
|
103
|
+
mounted.current = false;
|
|
104
|
+
clearTimeout(statusTimer.current);
|
|
105
|
+
clearTimeout(closeTimer.current);
|
|
106
|
+
};
|
|
96
107
|
}, []);
|
|
97
108
|
|
|
98
109
|
const toggleCollapse = (id: string) => {
|
|
@@ -142,10 +153,19 @@ export function SaveManagerContent() {
|
|
|
142
153
|
|
|
143
154
|
const handleLoad = async (save: SaveRecord) => {
|
|
144
155
|
try {
|
|
145
|
-
// Stored records hold serialized variables; the store expects live
|
|
146
|
-
|
|
156
|
+
// Stored records hold serialized variables; the store expects live
|
|
157
|
+
// ones. The game moves to the save's playthrough.
|
|
158
|
+
loadFromPayload(
|
|
159
|
+
deserializePayload(save.payload),
|
|
160
|
+
undefined,
|
|
161
|
+
save.meta.playthroughId,
|
|
162
|
+
);
|
|
147
163
|
showStatus('Game loaded');
|
|
148
|
-
if (closeDialog)
|
|
164
|
+
if (closeDialog) {
|
|
165
|
+
closeTimer.current = window.setTimeout(closeDialog, 500);
|
|
166
|
+
}
|
|
167
|
+
// A playthrough deleted since the list was read is recorded again
|
|
168
|
+
await refresh();
|
|
149
169
|
} catch {
|
|
150
170
|
showStatus('Failed to load save', 'error');
|
|
151
171
|
}
|
|
@@ -229,10 +249,7 @@ export function SaveManagerContent() {
|
|
|
229
249
|
showStatus('Save imported');
|
|
230
250
|
await refresh();
|
|
231
251
|
} catch (err) {
|
|
232
|
-
showStatus(
|
|
233
|
-
err instanceof Error ? err.message : 'Failed to import save',
|
|
234
|
-
'error',
|
|
235
|
-
);
|
|
252
|
+
showStatus(errorMessage(err, 'Failed to import save'), 'error');
|
|
236
253
|
}
|
|
237
254
|
};
|
|
238
255
|
|
|
@@ -5,7 +5,8 @@ defineMacro({
|
|
|
5
5
|
name: 'set',
|
|
6
6
|
render({ rawArgs }, ctx) {
|
|
7
7
|
const ran = ctx.hooks.useRef(false);
|
|
8
|
-
|
|
8
|
+
// Boxed: anything can be thrown, including null and other falsy values
|
|
9
|
+
const failure = ctx.hooks.useRef<{ error: unknown } | null>(null);
|
|
9
10
|
|
|
10
11
|
if (!ran.current) {
|
|
11
12
|
ran.current = true;
|
|
@@ -13,7 +14,7 @@ defineMacro({
|
|
|
13
14
|
try {
|
|
14
15
|
ctx.mutate(rawArgs);
|
|
15
16
|
} catch (err) {
|
|
16
|
-
|
|
17
|
+
failure.current = { error: err };
|
|
17
18
|
console.error(
|
|
18
19
|
`spindle: Error in {set ${rawArgs}}${ctx.sourceLocation()}:`,
|
|
19
20
|
err,
|
|
@@ -21,11 +22,11 @@ defineMacro({
|
|
|
21
22
|
}
|
|
22
23
|
}
|
|
23
24
|
|
|
24
|
-
if (
|
|
25
|
+
if (failure.current) {
|
|
25
26
|
return (
|
|
26
27
|
<MacroError
|
|
27
28
|
macro="set"
|
|
28
|
-
error={
|
|
29
|
+
error={failure.current.error}
|
|
29
30
|
/>
|
|
30
31
|
);
|
|
31
32
|
}
|