@rohal12/spindle 0.51.4 → 0.52.0
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 +4313 -1640
- package/dist/pkg/macro-registry.json +7 -7
- package/dist/pkg/story-variables.js +1636 -177
- package/package.json +5 -2
- 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 +19 -13
- 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/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 +30 -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 +270 -67
- package/src/expression.ts +86 -55
- package/src/hooks/use-action.ts +18 -3
- package/src/hooks/use-interpolate.ts +36 -5
- package/src/index.tsx +10 -1
- package/src/interpolation.ts +394 -96
- package/src/js-lexer.ts +1231 -97
- 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 +8 -8
- package/src/registry.ts +35 -0
- package/src/saves/save-manager.ts +339 -158
- package/src/saves/storage.ts +16 -7
- package/src/saves/types.ts +2 -1
- package/src/store.ts +521 -154
- package/src/story-api.ts +31 -67
- package/src/story-init.ts +1 -1
- package/src/story-variables.ts +98 -102
- package/src/triggers.ts +6 -5
- 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
|
@@ -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,11 @@
|
|
|
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';
|
|
7
9
|
|
|
8
10
|
function parseComputedArgs(rawArgs: string): { target: string; expr: string } {
|
|
9
11
|
const trimmed = rawArgs.trim();
|
|
@@ -29,6 +31,7 @@ function parseComputedArgs(rawArgs: string): { target: string; expr: string } {
|
|
|
29
31
|
);
|
|
30
32
|
}
|
|
31
33
|
|
|
34
|
+
checkVariableName(target.slice(1), target);
|
|
32
35
|
return { target, expr };
|
|
33
36
|
}
|
|
34
37
|
}
|
|
@@ -38,22 +41,29 @@ function parseComputedArgs(rawArgs: string): { target: string; expr: string } {
|
|
|
38
41
|
);
|
|
39
42
|
}
|
|
40
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Evaluate `expr` against the current values — the store's state and the
|
|
46
|
+
* enclosing locals scope's live values (`getLocals`) — and write the result
|
|
47
|
+
* to the target if it changed. Reading live values rather than the render's
|
|
48
|
+
* snapshot means the first computation sees a local a preceding {set} just
|
|
49
|
+
* assigned (the scope's context value only catches up on its re-render), and
|
|
50
|
+
* the first computation and later recomputations read the same source.
|
|
51
|
+
*/
|
|
41
52
|
function computeAndApply(
|
|
42
53
|
expr: string,
|
|
43
54
|
name: string,
|
|
44
55
|
isTemp: boolean,
|
|
45
56
|
isLocal: boolean,
|
|
46
|
-
|
|
47
|
-
temporary: Record<string, unknown>,
|
|
48
|
-
locals: Record<string, unknown>,
|
|
49
|
-
transient: Record<string, unknown>,
|
|
57
|
+
getLocals: () => Record<string, unknown>,
|
|
50
58
|
rawArgs: string,
|
|
51
59
|
prevRef: { current: unknown },
|
|
52
60
|
localsUpdate: ((key: string, value: unknown) => void) | null,
|
|
53
61
|
): void {
|
|
54
62
|
let newValue: unknown;
|
|
55
63
|
try {
|
|
56
|
-
|
|
64
|
+
// In program order, also when mutation code sets this off
|
|
65
|
+
const { variables, temporary, transient } = readState();
|
|
66
|
+
newValue = evaluate(expr, variables, temporary, getLocals(), transient);
|
|
57
67
|
} catch (err) {
|
|
58
68
|
console.error(
|
|
59
69
|
`spindle: Error in {computed ${rawArgs}}${currentSourceLocation()}:`,
|
|
@@ -114,26 +124,22 @@ defineMacro({
|
|
|
114
124
|
name,
|
|
115
125
|
isTemp,
|
|
116
126
|
isLocal,
|
|
117
|
-
|
|
118
|
-
mergedTemps,
|
|
119
|
-
mergedLocals,
|
|
120
|
-
mergedTrans,
|
|
127
|
+
ctx.getValues,
|
|
121
128
|
rawArgs,
|
|
122
129
|
prevOutput,
|
|
123
130
|
localsUpdate,
|
|
124
131
|
);
|
|
125
132
|
}
|
|
126
133
|
|
|
134
|
+
// The merged values only decide when to recompute; computeAndApply reads
|
|
135
|
+
// the current ones.
|
|
127
136
|
ctx.hooks.useLayoutEffect(() => {
|
|
128
137
|
computeAndApply(
|
|
129
138
|
expr,
|
|
130
139
|
name,
|
|
131
140
|
isTemp,
|
|
132
141
|
isLocal,
|
|
133
|
-
|
|
134
|
-
mergedTemps,
|
|
135
|
-
mergedLocals,
|
|
136
|
-
mergedTrans,
|
|
142
|
+
ctx.getValues,
|
|
137
143
|
rawArgs,
|
|
138
144
|
prevOutput,
|
|
139
145
|
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
|
|
|
@@ -47,4 +47,17 @@ defineMacro({
|
|
|
47
47
|
|
|
48
48
|
return null;
|
|
49
49
|
},
|
|
50
|
+
text({ rawArgs, branches = [] }, ctx) {
|
|
51
|
+
const switchValue = ctx.evaluate(rawArgs);
|
|
52
|
+
let defaultBranch: (typeof branches)[number] | null = null;
|
|
53
|
+
for (let i = 1; i < branches.length; i++) {
|
|
54
|
+
const branch = branches[i]!;
|
|
55
|
+
if (branch.rawArgs === '') {
|
|
56
|
+
defaultBranch = branch;
|
|
57
|
+
} else if (switchValue === ctx.evaluate(branch.rawArgs)) {
|
|
58
|
+
return ctx.renderText(branch.children);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return defaultBranch ? ctx.renderText(defaultBranch.children) : '';
|
|
62
|
+
},
|
|
50
63
|
});
|
|
@@ -1,31 +1,51 @@
|
|
|
1
1
|
import { useStoryStore } from '../../store';
|
|
2
2
|
import { defineMacro } from '../../define-macro';
|
|
3
|
+
import { MacroError } from './MacroError';
|
|
3
4
|
|
|
4
5
|
defineMacro({
|
|
5
6
|
name: 'unset',
|
|
6
7
|
render({ rawArgs }, ctx) {
|
|
7
8
|
const ran = ctx.hooks.useRef(false);
|
|
9
|
+
const error = ctx.hooks.useRef<unknown>(null);
|
|
8
10
|
|
|
9
11
|
if (!ran.current) {
|
|
10
12
|
ran.current = true;
|
|
11
13
|
const state = useStoryStore.getState();
|
|
12
14
|
const name = rawArgs.trim();
|
|
13
15
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
16
|
+
try {
|
|
17
|
+
if (name.startsWith('$')) {
|
|
18
|
+
state.deleteVariable(name.slice(1));
|
|
19
|
+
} else if (name.startsWith('_')) {
|
|
20
|
+
state.deleteTemporary(name.slice(1));
|
|
21
|
+
} else if (name.startsWith('%')) {
|
|
22
|
+
state.deleteTransient(name.slice(1));
|
|
23
|
+
} else if (name.startsWith('@')) {
|
|
24
|
+
// Throws outside a locals scope ({for}, widget, {link}, {button})
|
|
25
|
+
ctx.update(name.slice(1), undefined);
|
|
26
|
+
} else {
|
|
27
|
+
throw new Error(
|
|
28
|
+
`{unset} expects a variable ($name, _name, %name, or @name), got "${name}"`,
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
} catch (err) {
|
|
32
|
+
error.current = err;
|
|
23
33
|
console.error(
|
|
24
|
-
`spindle: {unset
|
|
34
|
+
`spindle: Error in {unset ${rawArgs}}${ctx.sourceLocation()}:`,
|
|
35
|
+
err,
|
|
25
36
|
);
|
|
26
37
|
}
|
|
27
38
|
}
|
|
28
39
|
|
|
40
|
+
if (error.current) {
|
|
41
|
+
return (
|
|
42
|
+
<MacroError
|
|
43
|
+
macro="unset"
|
|
44
|
+
error={error.current}
|
|
45
|
+
/>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
29
49
|
return null;
|
|
30
50
|
},
|
|
31
51
|
});
|
|
@@ -2,6 +2,14 @@ import { useStoryStore } from '../../store';
|
|
|
2
2
|
import { useContext } from 'preact/hooks';
|
|
3
3
|
import { LocalsValuesContext } from '../../markup/render';
|
|
4
4
|
import { useInterpolate } from '../../hooks/use-interpolate';
|
|
5
|
+
import { RESERVED_NAME, ownValue } from '../../utils/namespace';
|
|
6
|
+
|
|
7
|
+
const SIGILS = {
|
|
8
|
+
variable: '$',
|
|
9
|
+
temporary: '_',
|
|
10
|
+
local: '@',
|
|
11
|
+
transient: '%',
|
|
12
|
+
} as const;
|
|
5
13
|
|
|
6
14
|
interface VarDisplayProps {
|
|
7
15
|
name: string;
|
|
@@ -17,19 +25,28 @@ export function VarDisplay({ name, scope, className, id }: VarDisplayProps) {
|
|
|
17
25
|
const localsValues = useContext(LocalsValuesContext);
|
|
18
26
|
const parts = name.split('.');
|
|
19
27
|
const root = parts[0]!;
|
|
28
|
+
// Own entries only, as the expression engine reads them
|
|
20
29
|
const storeValue = useStoryStore((s) =>
|
|
21
30
|
scope === 'variable'
|
|
22
|
-
? s.variables
|
|
31
|
+
? ownValue(s.variables, root)
|
|
23
32
|
: scope === 'temporary'
|
|
24
|
-
? s.temporary
|
|
33
|
+
? ownValue(s.temporary, root)
|
|
25
34
|
: scope === 'transient'
|
|
26
|
-
? s.transient
|
|
35
|
+
? ownValue(s.transient, root)
|
|
27
36
|
: undefined,
|
|
28
37
|
);
|
|
29
38
|
|
|
39
|
+
if (root === RESERVED_NAME) {
|
|
40
|
+
return (
|
|
41
|
+
<span class="error">
|
|
42
|
+
{`{${SIGILS[scope]}${name} error: "${SIGILS[scope]}${root}" cannot be used as a variable name (${RESERVED_NAME} is reserved)}`}
|
|
43
|
+
</span>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
30
47
|
let value: unknown;
|
|
31
48
|
if (scope === 'local') {
|
|
32
|
-
value =
|
|
49
|
+
value = ownValue(localsValues, root);
|
|
33
50
|
} else {
|
|
34
51
|
value = storeValue;
|
|
35
52
|
}
|