@rohal12/spindle 0.51.3 → 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 +4833 -1603
- package/dist/pkg/macro-registry.json +7 -7
- package/dist/pkg/story-variables.js +1658 -189
- package/package.json +5 -2
- package/src/automation/runner.ts +2 -1
- package/src/class-registry.ts +277 -90
- 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 +9 -32
- package/src/components/macros/Checkbox.tsx +10 -4
- package/src/components/macros/Computed.tsx +19 -13
- package/src/components/macros/Dialog.tsx +4 -1
- package/src/components/macros/For.tsx +33 -4
- package/src/components/macros/If.tsx +8 -0
- package/src/components/macros/Include.tsx +15 -24
- package/src/components/macros/MacroError.tsx +2 -1
- package/src/components/macros/MacroLink.tsx +14 -46
- package/src/components/macros/Meter.tsx +26 -40
- 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 +27 -2
- package/src/components/macros/SaveManager.tsx +39 -14
- 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/Watch.tsx +88 -40
- package/src/components/macros/Widget.tsx +20 -1
- package/src/components/macros/WidgetInvocation.tsx +20 -158
- package/src/components/macros/arg-utils.ts +226 -0
- package/src/components/macros/detached-body.tsx +68 -0
- package/src/components/macros/option-utils.ts +10 -5
- package/src/define-macro.ts +44 -38
- package/src/execute-mutation.ts +499 -28
- package/src/expression.ts +88 -272
- 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 +1460 -0
- package/src/markup/code-attributes.ts +64 -0
- package/src/markup/markdown.ts +188 -9
- package/src/markup/render.tsx +552 -113
- package/src/markup/tokenizer.ts +601 -119
- package/src/prng.ts +8 -8
- package/src/registry.ts +35 -0
- package/src/saves/save-manager.ts +368 -153
- package/src/saves/storage.ts +24 -7
- package/src/saves/types.ts +20 -6
- package/src/store.ts +549 -137
- package/src/story-api.ts +46 -81
- package/src/story-init.ts +1 -1
- package/src/story-variables.ts +98 -102
- package/src/triggers.ts +6 -2
- 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 +194 -0
- package/src/utils/stable-key.ts +82 -0
- package/src/widgets/widget-registry.ts +9 -0
- package/types/index.d.ts +43 -7
- package/types/tooling.d.ts +1 -0
|
@@ -1,15 +1,21 @@
|
|
|
1
1
|
import { defineMacro } from '../../define-macro';
|
|
2
|
+
import { readWholeQuoted, stripLooseQuotes } from './arg-utils';
|
|
2
3
|
|
|
3
|
-
function
|
|
4
|
-
|
|
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);
|
|
9
|
+
if (!match) return '';
|
|
10
|
+
const rest = match[1]!.trimEnd() || match[1]!;
|
|
11
|
+
return readWholeQuoted(rest) ?? stripLooseQuotes(rest);
|
|
6
12
|
}
|
|
7
13
|
|
|
8
14
|
defineMacro({
|
|
9
15
|
name: 'checkbox',
|
|
10
16
|
storeVar: true,
|
|
11
17
|
render({ rawArgs }, ctx) {
|
|
12
|
-
const label =
|
|
18
|
+
const label = parseCheckboxLabel(rawArgs);
|
|
13
19
|
|
|
14
20
|
ctx.useAction({
|
|
15
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,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { defineMacro } from '../../define-macro';
|
|
2
2
|
import { PassageDialog } from '../PassageDialog';
|
|
3
|
+
import { readWholeQuoted } from './arg-utils';
|
|
3
4
|
|
|
4
5
|
defineMacro({
|
|
5
6
|
name: 'dialog',
|
|
@@ -11,7 +12,9 @@ defineMacro({
|
|
|
11
12
|
const noclose = /\bnoclose\s*$/.test(rawArgs);
|
|
12
13
|
const labelRaw = rawArgs.replace(/\bnoclose\s*$/, '').trim();
|
|
13
14
|
const label =
|
|
14
|
-
ctx.resolve?.(
|
|
15
|
+
ctx.resolve?.(
|
|
16
|
+
readWholeQuoted(labelRaw) ?? labelRaw.replace(/^["']|["']$/g, ''),
|
|
17
|
+
) ?? labelRaw;
|
|
15
18
|
const passageName = ctx
|
|
16
19
|
.collectText(children)
|
|
17
20
|
.trim()
|
|
@@ -9,11 +9,19 @@ import {
|
|
|
9
9
|
LocalsValuesContext,
|
|
10
10
|
LocalsUpdateContext,
|
|
11
11
|
NobrContext,
|
|
12
|
+
InlineContext,
|
|
13
|
+
RawTextContext,
|
|
12
14
|
renderNodes,
|
|
13
15
|
} from '../../markup/render';
|
|
14
16
|
import { defineMacro } from '../../define-macro';
|
|
15
17
|
import { MacroError } from './MacroError';
|
|
18
|
+
import { stableKey } from '../../utils/stable-key';
|
|
16
19
|
import type { ASTNode } from '../../markup/ast';
|
|
20
|
+
import {
|
|
21
|
+
checkVariableName,
|
|
22
|
+
createNamespace,
|
|
23
|
+
withEntry,
|
|
24
|
+
} from '../../utils/namespace';
|
|
17
25
|
|
|
18
26
|
/**
|
|
19
27
|
* Parse for-loop args: "@item, @i of $list" or "@item of $list"
|
|
@@ -44,6 +52,9 @@ function parseForArgs(rawArgs: string): {
|
|
|
44
52
|
);
|
|
45
53
|
}
|
|
46
54
|
|
|
55
|
+
checkVariableName(itemVar.slice(1), itemVar);
|
|
56
|
+
if (indexVar) checkVariableName(indexVar.slice(1), indexVar);
|
|
57
|
+
|
|
47
58
|
return {
|
|
48
59
|
itemVar: itemVar.slice(1),
|
|
49
60
|
indexVar: indexVar ? indexVar.slice(1) : null,
|
|
@@ -79,7 +90,7 @@ function ForIteration({
|
|
|
79
90
|
);
|
|
80
91
|
|
|
81
92
|
const localState = useMemo(
|
|
82
|
-
() => (
|
|
93
|
+
() => createNamespace(parentValues, ownKeys, localMutations),
|
|
83
94
|
[parentValues, ownKeys, localMutations],
|
|
84
95
|
);
|
|
85
96
|
|
|
@@ -91,17 +102,20 @@ function ForIteration({
|
|
|
91
102
|
// Apply synchronously so later macros in the same render pass (e.g. a
|
|
92
103
|
// second {set}) read the new value via getValues(); the state update
|
|
93
104
|
// then re-renders consumers of LocalsValuesContext.
|
|
94
|
-
|
|
105
|
+
checkVariableName(key, `@${key}`);
|
|
106
|
+
valuesRef.current = withEntry(valuesRef.current, key, value);
|
|
95
107
|
setLocalMutations((prev) => ({ ...prev, [key]: value }));
|
|
96
108
|
}, []);
|
|
97
109
|
const updater = useMemo(() => ({ update, getValues }), [update, getValues]);
|
|
98
110
|
|
|
99
111
|
const nobr = useContext(NobrContext);
|
|
112
|
+
const inline = useContext(InlineContext);
|
|
113
|
+
const raw = useContext(RawTextContext);
|
|
100
114
|
|
|
101
115
|
return (
|
|
102
116
|
<LocalsUpdateContext.Provider value={updater}>
|
|
103
117
|
<LocalsValuesContext.Provider value={localState}>
|
|
104
|
-
{renderNodes(children, { nobr, locals: localState })}
|
|
118
|
+
{renderNodes(children, { nobr, inline, raw, locals: localState })}
|
|
105
119
|
</LocalsValuesContext.Provider>
|
|
106
120
|
</LocalsUpdateContext.Provider>
|
|
107
121
|
);
|
|
@@ -151,7 +165,7 @@ defineMacro({
|
|
|
151
165
|
|
|
152
166
|
const content = list.map((item, i) => (
|
|
153
167
|
<ForIteration
|
|
154
|
-
key={`${i}-${
|
|
168
|
+
key={`${i}-${stableKey(item)}`}
|
|
155
169
|
parentValues={parentValues}
|
|
156
170
|
itemVar={itemVar}
|
|
157
171
|
itemValue={item}
|
|
@@ -163,4 +177,19 @@ defineMacro({
|
|
|
163
177
|
|
|
164
178
|
return ctx.wrap(content);
|
|
165
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
|
+
},
|
|
166
195
|
});
|
|
@@ -3,6 +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 { endsWithOperator, isWhitespace, topLevelIndices } from './arg-utils';
|
|
6
7
|
|
|
7
8
|
const FLAG = 'inline';
|
|
8
9
|
|
|
@@ -12,49 +13,39 @@ const FLAG = 'inline';
|
|
|
12
13
|
* braces, separated by whitespace from the target expression, so passage
|
|
13
14
|
* names and expressions containing the word stay intact (#201).
|
|
14
15
|
*/
|
|
15
|
-
function parseIncludeArgs(rawArgs: string): {
|
|
16
|
+
export function parseIncludeArgs(rawArgs: string): {
|
|
16
17
|
nameExpr: string;
|
|
17
18
|
inline: boolean;
|
|
18
19
|
} {
|
|
19
20
|
const trimmed = rawArgs.trim();
|
|
20
21
|
|
|
21
22
|
// First and last whitespace runs at depth 0, as [start, end) offsets.
|
|
23
|
+
const spaces = topLevelIndices(trimmed, isWhitespace);
|
|
22
24
|
let first: [number, number] | null = null;
|
|
23
25
|
let last: [number, number] | null = null;
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
continue;
|
|
32
|
-
}
|
|
33
|
-
if (ch === '"' || ch === "'" || ch === '`') inString = ch;
|
|
34
|
-
else if (ch === '(' || ch === '[' || ch === '{') depth++;
|
|
35
|
-
else if (ch === ')' || ch === ']' || ch === '}') depth--;
|
|
36
|
-
else if (depth === 0 && /\s/.test(ch)) {
|
|
37
|
-
let end = i + 1;
|
|
38
|
-
while (end < trimmed.length && /\s/.test(trimmed[end]!)) end++;
|
|
39
|
-
last = [i, end];
|
|
40
|
-
first ??= last;
|
|
41
|
-
i = end - 1;
|
|
42
|
-
}
|
|
26
|
+
if (spaces.length > 0) {
|
|
27
|
+
let s = 0;
|
|
28
|
+
while (s + 1 < spaces.length && spaces[s + 1] === spaces[s]! + 1) s++;
|
|
29
|
+
first = [spaces[0]!, spaces[s]! + 1];
|
|
30
|
+
let e = spaces.length - 1;
|
|
31
|
+
while (e > 0 && spaces[e - 1] === spaces[e]! - 1) e--;
|
|
32
|
+
last = [spaces[e]!, spaces[spaces.length - 1]! + 1];
|
|
43
33
|
}
|
|
44
34
|
|
|
45
|
-
// 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.
|
|
46
37
|
if (
|
|
47
38
|
last &&
|
|
48
39
|
last[1] === trimmed.length - FLAG.length &&
|
|
49
40
|
trimmed.endsWith(FLAG)
|
|
50
41
|
) {
|
|
51
42
|
const rest = trimmed.slice(0, last[0]);
|
|
52
|
-
if (
|
|
53
|
-
return { nameExpr: rest, inline: true };
|
|
43
|
+
if (!endsWithOperator(rest)) return { nameExpr: rest, inline: true };
|
|
54
44
|
}
|
|
55
45
|
if (first && first[0] === FLAG.length && trimmed.startsWith(FLAG)) {
|
|
56
46
|
const rest = trimmed.slice(first[1]);
|
|
57
|
-
|
|
47
|
+
// `inline %name` reads a transient variable; `inline % 2` is modulo.
|
|
48
|
+
if (!/^(?:[-+*/&|^=<>?:,.]|%(?![A-Za-z_]))/.test(rest))
|
|
58
49
|
return { nameExpr: rest, inline: true };
|
|
59
50
|
}
|
|
60
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,16 +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 {
|
|
3
|
+
import { unescapeQuoted } from './arg-utils';
|
|
4
|
+
import { useDetachedBody } from './detached-body';
|
|
12
5
|
|
|
13
|
-
function
|
|
6
|
+
export function parseLinkArgs(rawArgs: string): {
|
|
14
7
|
display: string;
|
|
15
8
|
passage: string | null;
|
|
16
9
|
} {
|
|
@@ -20,7 +13,7 @@ function parseArgs(rawArgs: string): {
|
|
|
20
13
|
const re = /(["'])((?:\\[^]|(?!\1)[^\\])*)\1/g;
|
|
21
14
|
let m;
|
|
22
15
|
while ((m = re.exec(rawArgs)) !== null) {
|
|
23
|
-
parts.push(m[2]
|
|
16
|
+
parts.push(unescapeQuoted(m[2]!));
|
|
24
17
|
}
|
|
25
18
|
if (parts.length >= 2) {
|
|
26
19
|
return { display: parts[0]!, passage: parts[1]! };
|
|
@@ -32,58 +25,33 @@ function parseArgs(rawArgs: string): {
|
|
|
32
25
|
return { display: rawArgs.trim(), passage: null };
|
|
33
26
|
}
|
|
34
27
|
|
|
35
|
-
function renderChildrenDetached(
|
|
36
|
-
children: import('../../markup/ast').ASTNode[],
|
|
37
|
-
getValues: () => Record<string, unknown>,
|
|
38
|
-
update: (key: string, value: unknown) => void,
|
|
39
|
-
nobr: boolean,
|
|
40
|
-
) {
|
|
41
|
-
const container = document.createElement('div');
|
|
42
|
-
const vnode = h(
|
|
43
|
-
NobrContext.Provider,
|
|
44
|
-
{ value: nobr },
|
|
45
|
-
h(
|
|
46
|
-
LocalsUpdateContext.Provider,
|
|
47
|
-
{ value: { update, getValues } },
|
|
48
|
-
h(
|
|
49
|
-
LocalsValuesContext.Provider,
|
|
50
|
-
{ value: liveLocalsView(getValues) },
|
|
51
|
-
renderNodes(children),
|
|
52
|
-
),
|
|
53
|
-
),
|
|
54
|
-
);
|
|
55
|
-
render(vnode, container);
|
|
56
|
-
render(null, container);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
28
|
defineMacro({
|
|
60
29
|
name: 'link',
|
|
61
30
|
block: true,
|
|
62
31
|
interpolate: true,
|
|
63
32
|
render({ rawArgs, children = [] }, ctx) {
|
|
64
|
-
const { display, passage } =
|
|
65
|
-
const
|
|
33
|
+
const { display, passage } = parseLinkArgs(rawArgs);
|
|
34
|
+
const runBody = useDetachedBody();
|
|
66
35
|
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
renderChildrenDetached(children, ctx.getValues, ctx.update, nobr);
|
|
36
|
+
const perform = () => {
|
|
37
|
+
runBody(children);
|
|
70
38
|
if (passage) {
|
|
71
39
|
useStoryStore.getState().navigate(passage);
|
|
72
40
|
}
|
|
73
41
|
};
|
|
74
42
|
|
|
43
|
+
const handleClick = (e: Event) => {
|
|
44
|
+
e.preventDefault();
|
|
45
|
+
perform();
|
|
46
|
+
};
|
|
47
|
+
|
|
75
48
|
ctx.useAction({
|
|
76
49
|
type: 'link',
|
|
77
50
|
key: passage || display,
|
|
78
51
|
authorId: ctx.id,
|
|
79
52
|
label: display,
|
|
80
53
|
target: passage ?? undefined,
|
|
81
|
-
perform
|
|
82
|
-
renderChildrenDetached(children, ctx.getValues, ctx.update, nobr);
|
|
83
|
-
if (passage) {
|
|
84
|
-
useStoryStore.getState().navigate(passage);
|
|
85
|
-
}
|
|
86
|
-
},
|
|
54
|
+
perform,
|
|
87
55
|
});
|
|
88
56
|
|
|
89
57
|
return (
|
|
@@ -1,63 +1,49 @@
|
|
|
1
1
|
import { defineMacro } from '../../define-macro';
|
|
2
2
|
import { MacroError } from './MacroError';
|
|
3
|
+
import {
|
|
4
|
+
endsWithOperator,
|
|
5
|
+
isWhitespace,
|
|
6
|
+
readWholeQuoted,
|
|
7
|
+
splitTopLevel,
|
|
8
|
+
} from './arg-utils';
|
|
3
9
|
|
|
4
|
-
|
|
10
|
+
/**
|
|
11
|
+
* Parse `{meter currentExpr maxExpr ["label"]}`. Arguments are separated by
|
|
12
|
+
* whitespace outside string and template literals and bracket pairs; a
|
|
13
|
+
* trailing standalone `"…"` or `'…'` string is the label mode, with `\"`,
|
|
14
|
+
* `\'` and `\\` escapes. A string after an operator (`$a ?? "5"`) is an
|
|
15
|
+
* operand of the max expression, not the label.
|
|
16
|
+
*/
|
|
17
|
+
export function parseMeterArgs(rawArgs: string): {
|
|
5
18
|
currentExpr: string;
|
|
6
19
|
maxExpr: string;
|
|
7
20
|
labelMode: string;
|
|
8
21
|
} {
|
|
9
|
-
const
|
|
22
|
+
const tokens = splitTopLevel(rawArgs.trim(), isWhitespace).filter(Boolean);
|
|
10
23
|
|
|
11
|
-
// Extract quoted label mode from the end if present
|
|
12
24
|
let labelMode = '';
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
25
|
+
const label =
|
|
26
|
+
tokens.length >= 2 && !endsWithOperator(tokens.slice(0, -1).join(' '))
|
|
27
|
+
? readWholeQuoted(tokens[tokens.length - 1]!)
|
|
28
|
+
: null;
|
|
29
|
+
if (label !== null) {
|
|
30
|
+
labelMode = label;
|
|
31
|
+
tokens.pop();
|
|
18
32
|
}
|
|
19
33
|
|
|
20
|
-
|
|
21
|
-
const exprs = splitExpressions(rest);
|
|
22
|
-
if (exprs.length < 2) {
|
|
34
|
+
if (tokens.length < 2) {
|
|
23
35
|
throw new Error(
|
|
24
36
|
'meter requires two arguments: {meter currentExpr maxExpr}',
|
|
25
37
|
);
|
|
26
38
|
}
|
|
27
39
|
|
|
28
40
|
return {
|
|
29
|
-
currentExpr:
|
|
30
|
-
maxExpr:
|
|
41
|
+
currentExpr: tokens[0]!,
|
|
42
|
+
maxExpr: tokens.slice(1).join(' '),
|
|
31
43
|
labelMode,
|
|
32
44
|
};
|
|
33
45
|
}
|
|
34
46
|
|
|
35
|
-
function splitExpressions(input: string): string[] {
|
|
36
|
-
const result: string[] = [];
|
|
37
|
-
let current = '';
|
|
38
|
-
let depth = 0;
|
|
39
|
-
|
|
40
|
-
for (let i = 0; i < input.length; i++) {
|
|
41
|
-
const ch = input[i]!;
|
|
42
|
-
if (ch === '(' || ch === '[') {
|
|
43
|
-
depth++;
|
|
44
|
-
current += ch;
|
|
45
|
-
} else if (ch === ')' || ch === ']') {
|
|
46
|
-
depth--;
|
|
47
|
-
current += ch;
|
|
48
|
-
} else if (/\s/.test(ch) && depth === 0 && current.length > 0) {
|
|
49
|
-
result.push(current);
|
|
50
|
-
current = '';
|
|
51
|
-
// Skip additional whitespace
|
|
52
|
-
while (i + 1 < input.length && /\s/.test(input[i + 1]!)) i++;
|
|
53
|
-
} else {
|
|
54
|
-
current += ch;
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
if (current.length > 0) result.push(current);
|
|
58
|
-
return result;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
47
|
function formatLabel(
|
|
62
48
|
current: number,
|
|
63
49
|
max: number,
|
|
@@ -76,7 +62,7 @@ defineMacro({
|
|
|
76
62
|
merged: true,
|
|
77
63
|
render({ rawArgs }, ctx) {
|
|
78
64
|
try {
|
|
79
|
-
const { currentExpr, maxExpr, labelMode } =
|
|
65
|
+
const { currentExpr, maxExpr, labelMode } = parseMeterArgs(rawArgs);
|
|
80
66
|
const current = Number(ctx.evaluate!(currentExpr));
|
|
81
67
|
const max = Number(ctx.evaluate!(maxExpr));
|
|
82
68
|
const pct =
|
|
@@ -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();
|
|
@@ -1,8 +1,33 @@
|
|
|
1
1
|
import { defineMacro } from '../../define-macro';
|
|
2
|
+
import {
|
|
3
|
+
isWhitespace,
|
|
4
|
+
readQuoted,
|
|
5
|
+
readWholeQuoted,
|
|
6
|
+
stripLooseQuotes,
|
|
7
|
+
} from './arg-utils';
|
|
8
|
+
|
|
9
|
+
export function parseRadioArgs(rawArgs: string): {
|
|
10
|
+
value: string;
|
|
11
|
+
label: string;
|
|
12
|
+
} {
|
|
13
|
+
// $var "value" label: a quoted value accepts \" \' and \\ escapes and
|
|
14
|
+
// may contain the other quote kind.
|
|
15
|
+
const head = rawArgs.match(/^\s*["']?\$?[\w.]+["']?\s+/);
|
|
16
|
+
if (head) {
|
|
17
|
+
const rest = rawArgs.slice(head[0].length).trim();
|
|
18
|
+
const value = readQuoted(rest, 0);
|
|
19
|
+
if (
|
|
20
|
+
value &&
|
|
21
|
+
(value.end === rest.length || isWhitespace(rest[value.end]!))
|
|
22
|
+
) {
|
|
23
|
+
const labelRaw = rest.slice(value.end).trim();
|
|
24
|
+
const label = readWholeQuoted(labelRaw) ?? stripLooseQuotes(labelRaw);
|
|
25
|
+
return { value: value.value, label };
|
|
26
|
+
}
|
|
27
|
+
}
|
|
2
28
|
|
|
3
|
-
function parseRadioArgs(rawArgs: string): { value: string; label: string } {
|
|
4
29
|
const match = rawArgs.match(
|
|
5
|
-
/^\s*["']?\$?[\w.]+["']?\s+["'](.+?)["']\s+["']?(.+?)["']?\s
|
|
30
|
+
/^\s*["']?\$?[\w.]+["']?\s+["'](.+?)["']\s+["']?(.+?)["']?\s*$/s,
|
|
6
31
|
);
|
|
7
32
|
if (!match) {
|
|
8
33
|
const parts = rawArgs.trim().split(/\s+/).slice(1);
|