@rohal12/spindle 0.37.1 → 0.38.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/package.json +1 -1
- package/src/components/macros/ExprDisplay.tsx +39 -0
- package/src/components/macros/PassageDisplay.tsx +13 -10
- package/src/index.tsx +9 -2
- package/src/interpolation.ts +97 -19
- package/src/markup/ast.ts +24 -1
- package/src/markup/render.tsx +11 -0
- package/src/markup/tokenizer.ts +129 -12
- package/src/store.ts +18 -0
- package/src/story-api.ts +35 -0
package/package.json
CHANGED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { useContext } from 'preact/hooks';
|
|
2
|
+
import { useStoryStore } from '../../store';
|
|
3
|
+
import { evaluate } from '../../expression';
|
|
4
|
+
import { LocalsValuesContext } from '../../markup/render';
|
|
5
|
+
import { useInterpolate } from '../../hooks/use-interpolate';
|
|
6
|
+
|
|
7
|
+
interface ExprDisplayProps {
|
|
8
|
+
expression: string;
|
|
9
|
+
className?: string;
|
|
10
|
+
id?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function ExprDisplay({ expression, className, id }: ExprDisplayProps) {
|
|
14
|
+
const resolve = useInterpolate();
|
|
15
|
+
className = resolve(className);
|
|
16
|
+
id = resolve(id);
|
|
17
|
+
const localsValues = useContext(LocalsValuesContext);
|
|
18
|
+
const variables = useStoryStore((s) => s.variables);
|
|
19
|
+
const temporary = useStoryStore((s) => s.temporary);
|
|
20
|
+
|
|
21
|
+
let display: string;
|
|
22
|
+
try {
|
|
23
|
+
const value = evaluate(expression, variables, temporary, localsValues);
|
|
24
|
+
display = value == null ? '' : String(value);
|
|
25
|
+
} catch {
|
|
26
|
+
display = `{error: ${expression}}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (className || id)
|
|
30
|
+
return (
|
|
31
|
+
<span
|
|
32
|
+
id={id}
|
|
33
|
+
class={className}
|
|
34
|
+
>
|
|
35
|
+
{display}
|
|
36
|
+
</span>
|
|
37
|
+
);
|
|
38
|
+
return <>{display}</>;
|
|
39
|
+
}
|
|
@@ -71,6 +71,7 @@ defineMacro({
|
|
|
71
71
|
render(_props, ctx) {
|
|
72
72
|
const currentPassage = useStoryStore((s) => s.currentPassage);
|
|
73
73
|
const storyData = useStoryStore((s) => s.storyData);
|
|
74
|
+
const renderDeferred = useStoryStore((s) => s.renderDeferred);
|
|
74
75
|
|
|
75
76
|
// Render-gating: displayedPassage controls which passage is visually shown.
|
|
76
77
|
// The store updates immediately, but the visual swap is deferred by the
|
|
@@ -245,12 +246,14 @@ defineMacro({
|
|
|
245
246
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
246
247
|
}, [currentPassage]);
|
|
247
248
|
|
|
248
|
-
//
|
|
249
|
-
const
|
|
250
|
-
? storyData?.passages.get(
|
|
251
|
-
:
|
|
249
|
+
// When render is deferred, show StoryLoading passage or nothing
|
|
250
|
+
const effectivePassage = renderDeferred
|
|
251
|
+
? (storyData?.passages.get('StoryLoading') ?? null)
|
|
252
|
+
: displayedPassage
|
|
253
|
+
? storyData?.passages.get(displayedPassage)
|
|
254
|
+
: null;
|
|
252
255
|
|
|
253
|
-
if (!
|
|
256
|
+
if (!effectivePassage && displayedPassage && !renderDeferred) {
|
|
254
257
|
return (
|
|
255
258
|
<div class="error">
|
|
256
259
|
Error: Passage “{displayedPassage}” not found.
|
|
@@ -265,7 +268,7 @@ defineMacro({
|
|
|
265
268
|
id={ctx.id ?? 'story'}
|
|
266
269
|
class={ctx.className ?? 'story'}
|
|
267
270
|
>
|
|
268
|
-
{readyPassage && (
|
|
271
|
+
{!renderDeferred && readyPassage && (
|
|
269
272
|
<div
|
|
270
273
|
key={`ready-${currentPassage}`}
|
|
271
274
|
hidden
|
|
@@ -277,11 +280,11 @@ defineMacro({
|
|
|
277
280
|
class="passage-container"
|
|
278
281
|
ref={containerRef}
|
|
279
282
|
>
|
|
280
|
-
{
|
|
283
|
+
{effectivePassage && (
|
|
281
284
|
<Passage
|
|
282
|
-
passage={
|
|
283
|
-
key={displayedPassage}
|
|
284
|
-
dataTransition={resolvedTypeRef.current}
|
|
285
|
+
passage={effectivePassage}
|
|
286
|
+
key={renderDeferred ? 'loading' : displayedPassage}
|
|
287
|
+
dataTransition={renderDeferred ? 'none' : resolvedTypeRef.current}
|
|
285
288
|
/>
|
|
286
289
|
)}
|
|
287
290
|
</div>
|
package/src/index.tsx
CHANGED
|
@@ -2,7 +2,7 @@ import { render } from 'preact';
|
|
|
2
2
|
import { App } from './components/App';
|
|
3
3
|
import { parseStoryData } from './parser';
|
|
4
4
|
import { useStoryStore, fireStoryInit } from './store';
|
|
5
|
-
import { installStoryAPI } from './story-api';
|
|
5
|
+
import { installStoryAPI, getReadyPromise } from './story-api';
|
|
6
6
|
import { resetIdCounters } from './action-registry';
|
|
7
7
|
import { executeStoryInit } from './story-init';
|
|
8
8
|
import { checkTriggers, reinitTriggerState } from './triggers';
|
|
@@ -200,7 +200,14 @@ function boot() {
|
|
|
200
200
|
|
|
201
201
|
render(<App />, root);
|
|
202
202
|
|
|
203
|
-
|
|
203
|
+
const pending = getReadyPromise();
|
|
204
|
+
if (pending) {
|
|
205
|
+
pending.then(() => {
|
|
206
|
+
document.dispatchEvent(new CustomEvent(':storyready'));
|
|
207
|
+
});
|
|
208
|
+
} else {
|
|
209
|
+
document.dispatchEvent(new CustomEvent(':storyready'));
|
|
210
|
+
}
|
|
204
211
|
}
|
|
205
212
|
|
|
206
213
|
if (document.readyState === 'loading') {
|
package/src/interpolation.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
import { evaluate } from './expression';
|
|
2
|
+
|
|
3
|
+
/** Detects any {…} block that starts with a sigil ($, _, @). */
|
|
4
|
+
const INTERP_TEST = /\{[\$_@]\w/;
|
|
3
5
|
|
|
4
6
|
export function hasInterpolation(s: string): boolean {
|
|
5
7
|
return INTERP_TEST.test(s);
|
|
@@ -14,31 +16,107 @@ function resolveDotPath(root: unknown, parts: string[]): unknown {
|
|
|
14
16
|
return value;
|
|
15
17
|
}
|
|
16
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Evaluate an expression string and return its stringified result.
|
|
21
|
+
* Uses the full expression evaluator from expression.ts.
|
|
22
|
+
*/
|
|
23
|
+
export function interpolateExpression(
|
|
24
|
+
expr: string,
|
|
25
|
+
variables: Record<string, unknown>,
|
|
26
|
+
temporary: Record<string, unknown>,
|
|
27
|
+
locals: Record<string, unknown>,
|
|
28
|
+
): string {
|
|
29
|
+
const value = evaluate(expr, variables, temporary, locals);
|
|
30
|
+
return value == null ? '' : String(value);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function resolveSimple(
|
|
34
|
+
ref: string,
|
|
35
|
+
variables: Record<string, unknown>,
|
|
36
|
+
temporary: Record<string, unknown>,
|
|
37
|
+
locals: Record<string, unknown>,
|
|
38
|
+
): string {
|
|
39
|
+
const prefix = ref[0]!;
|
|
40
|
+
const path = ref.slice(1);
|
|
41
|
+
const parts = path.split('.');
|
|
42
|
+
const root = parts[0]!;
|
|
43
|
+
|
|
44
|
+
let value: unknown;
|
|
45
|
+
if (prefix === '$') {
|
|
46
|
+
value = variables[root];
|
|
47
|
+
} else if (prefix === '_') {
|
|
48
|
+
value = temporary[root];
|
|
49
|
+
} else {
|
|
50
|
+
value = locals[root];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (parts.length > 1) {
|
|
54
|
+
value = resolveDotPath(value, parts);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return value == null ? '' : String(value);
|
|
58
|
+
}
|
|
59
|
+
|
|
17
60
|
export function interpolate(
|
|
18
61
|
template: string,
|
|
19
62
|
variables: Record<string, unknown>,
|
|
20
63
|
temporary: Record<string, unknown>,
|
|
21
64
|
locals: Record<string, unknown>,
|
|
22
65
|
): string {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
if (
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
66
|
+
// Manual scan: process {…} blocks containing sigils.
|
|
67
|
+
// Simple dot-path refs use the fast resolver; everything else falls back
|
|
68
|
+
// to the full expression evaluator.
|
|
69
|
+
let result = '';
|
|
70
|
+
let i = 0;
|
|
71
|
+
|
|
72
|
+
while (i < template.length) {
|
|
73
|
+
if (template[i] !== '{') {
|
|
74
|
+
// Accumulate plain text
|
|
75
|
+
const nextBrace = template.indexOf('{', i);
|
|
76
|
+
if (nextBrace === -1) {
|
|
77
|
+
result += template.slice(i);
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
result += template.slice(i, nextBrace);
|
|
81
|
+
i = nextBrace;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Found { — check if it starts a sigil expression
|
|
86
|
+
i++; // skip {
|
|
87
|
+
|
|
88
|
+
const sigil = template[i];
|
|
89
|
+
if (sigil !== '$' && sigil !== '_' && sigil !== '@') {
|
|
90
|
+
// Not an interpolation — emit the { as text
|
|
91
|
+
result += '{';
|
|
92
|
+
continue;
|
|
36
93
|
}
|
|
37
94
|
|
|
38
|
-
|
|
39
|
-
|
|
95
|
+
// Scan for balanced closing }
|
|
96
|
+
let depth = 1;
|
|
97
|
+
let j = i;
|
|
98
|
+
while (j < template.length && depth > 0) {
|
|
99
|
+
j++;
|
|
100
|
+
if (template[j] === '{') depth++;
|
|
101
|
+
else if (template[j] === '}') depth--;
|
|
40
102
|
}
|
|
41
103
|
|
|
42
|
-
|
|
43
|
-
|
|
104
|
+
if (depth !== 0) {
|
|
105
|
+
// Unbalanced — emit as text
|
|
106
|
+
result += '{';
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const inner = template.slice(i, j);
|
|
111
|
+
i = j + 1; // skip past closing }
|
|
112
|
+
|
|
113
|
+
// Try simple dot-path match first
|
|
114
|
+
if (/^[\$_@][\w.]+$/.test(inner)) {
|
|
115
|
+
result += resolveSimple(inner, variables, temporary, locals);
|
|
116
|
+
} else {
|
|
117
|
+
result += interpolateExpression(inner, variables, temporary, locals);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return result;
|
|
44
122
|
}
|
package/src/markup/ast.ts
CHANGED
|
@@ -13,6 +13,13 @@ export interface VariableNode {
|
|
|
13
13
|
id?: string;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
export interface ExpressionNode {
|
|
17
|
+
type: 'expression';
|
|
18
|
+
expression: string;
|
|
19
|
+
className?: string;
|
|
20
|
+
id?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
16
23
|
export interface Branch {
|
|
17
24
|
rawArgs: string;
|
|
18
25
|
className?: string;
|
|
@@ -37,7 +44,12 @@ export interface HtmlNode {
|
|
|
37
44
|
children: ASTNode[];
|
|
38
45
|
}
|
|
39
46
|
|
|
40
|
-
export type ASTNode =
|
|
47
|
+
export type ASTNode =
|
|
48
|
+
| TextNode
|
|
49
|
+
| VariableNode
|
|
50
|
+
| ExpressionNode
|
|
51
|
+
| MacroNode
|
|
52
|
+
| HtmlNode;
|
|
41
53
|
|
|
42
54
|
/** Macros that require a closing tag and can contain children */
|
|
43
55
|
const BLOCK_MACROS = new Set([
|
|
@@ -131,6 +143,17 @@ export function buildAST(tokens: Token[]): ASTNode[] {
|
|
|
131
143
|
break;
|
|
132
144
|
}
|
|
133
145
|
|
|
146
|
+
case 'expression': {
|
|
147
|
+
const exprNode: ExpressionNode = {
|
|
148
|
+
type: 'expression',
|
|
149
|
+
expression: token.expression,
|
|
150
|
+
};
|
|
151
|
+
if (token.className) exprNode.className = token.className;
|
|
152
|
+
if (token.id) exprNode.id = token.id;
|
|
153
|
+
current().push(exprNode);
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
|
|
134
157
|
case 'html': {
|
|
135
158
|
if (token.isSelfClose) {
|
|
136
159
|
// Self-closing HTML tag (br, hr, img, etc.)
|
package/src/markup/render.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createContext } from 'preact';
|
|
2
2
|
import { useContext } from 'preact/hooks';
|
|
3
3
|
import { VarDisplay } from '../components/macros/VarDisplay';
|
|
4
|
+
import { ExprDisplay } from '../components/macros/ExprDisplay';
|
|
4
5
|
import { WidgetInvocation } from '../components/macros/WidgetInvocation';
|
|
5
6
|
import { getWidget } from '../widgets/widget-registry';
|
|
6
7
|
import { getMacro, isSubMacro } from '../registry';
|
|
@@ -181,6 +182,16 @@ function renderSingleNode(
|
|
|
181
182
|
/>
|
|
182
183
|
);
|
|
183
184
|
|
|
185
|
+
case 'expression':
|
|
186
|
+
return (
|
|
187
|
+
<ExprDisplay
|
|
188
|
+
key={key}
|
|
189
|
+
expression={node.expression}
|
|
190
|
+
className={node.className}
|
|
191
|
+
id={node.id}
|
|
192
|
+
/>
|
|
193
|
+
);
|
|
194
|
+
|
|
184
195
|
case 'macro':
|
|
185
196
|
return renderMacro(node, key);
|
|
186
197
|
|
package/src/markup/tokenizer.ts
CHANGED
|
@@ -36,6 +36,15 @@ export interface VariableToken {
|
|
|
36
36
|
end: number;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
export interface ExpressionToken {
|
|
40
|
+
type: 'expression';
|
|
41
|
+
expression: string;
|
|
42
|
+
className?: string;
|
|
43
|
+
id?: string;
|
|
44
|
+
start: number;
|
|
45
|
+
end: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
39
48
|
export interface HtmlToken {
|
|
40
49
|
type: 'html';
|
|
41
50
|
tag: string;
|
|
@@ -51,6 +60,7 @@ export type Token =
|
|
|
51
60
|
| LinkToken
|
|
52
61
|
| MacroToken
|
|
53
62
|
| VariableToken
|
|
63
|
+
| ExpressionToken
|
|
54
64
|
| HtmlToken;
|
|
55
65
|
|
|
56
66
|
/** Tag name must start with a letter (covers standard and custom elements). */
|
|
@@ -233,6 +243,20 @@ function parseHtmlAttributes(
|
|
|
233
243
|
return { attributes, endIdx: j };
|
|
234
244
|
}
|
|
235
245
|
|
|
246
|
+
/**
|
|
247
|
+
* Scan for the balanced closing } starting at position i (just past the {).
|
|
248
|
+
* Returns the index of the closing } or -1 if unbalanced.
|
|
249
|
+
*/
|
|
250
|
+
function scanBalancedBrace(input: string, i: number): number {
|
|
251
|
+
let depth = 1;
|
|
252
|
+
while (i < input.length && depth > 0) {
|
|
253
|
+
if (input[i] === '{') depth++;
|
|
254
|
+
else if (input[i] === '}') depth--;
|
|
255
|
+
if (depth > 0) i++;
|
|
256
|
+
}
|
|
257
|
+
return depth === 0 ? i : -1;
|
|
258
|
+
}
|
|
259
|
+
|
|
236
260
|
/**
|
|
237
261
|
* Single-pass tokenizer for Twine passage content.
|
|
238
262
|
* Recognizes: [[links]], {$variable}, {_temporary}, {macroName args}
|
|
@@ -341,7 +365,7 @@ export function tokenize(input: string): Token[] {
|
|
|
341
365
|
const charAfter = input[afterSelectors];
|
|
342
366
|
|
|
343
367
|
if (charAfter === '$') {
|
|
344
|
-
// {.class#id $variable.field}
|
|
368
|
+
// {.class#id $variable.field} or {.class $expr[...]}
|
|
345
369
|
i = afterSelectors + 1;
|
|
346
370
|
const nameStart = i;
|
|
347
371
|
while (i < input.length && /[\w.]/.test(input[i]!)) i++;
|
|
@@ -362,14 +386,31 @@ export function tokenize(input: string): Token[] {
|
|
|
362
386
|
textStart = i;
|
|
363
387
|
continue;
|
|
364
388
|
}
|
|
365
|
-
//
|
|
389
|
+
// Complex expression — scan for balanced closing }
|
|
390
|
+
const closeIdx$ = scanBalancedBrace(input, nameStart);
|
|
391
|
+
if (closeIdx$ !== -1) {
|
|
392
|
+
const expression = input.slice(afterSelectors, closeIdx$);
|
|
393
|
+
i = closeIdx$ + 1;
|
|
394
|
+
const token: ExpressionToken = {
|
|
395
|
+
type: 'expression',
|
|
396
|
+
expression,
|
|
397
|
+
start,
|
|
398
|
+
end: i,
|
|
399
|
+
};
|
|
400
|
+
if (className) token.className = className;
|
|
401
|
+
if (id) token.id = id;
|
|
402
|
+
tokens.push(token);
|
|
403
|
+
textStart = i;
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
// Unbalanced — treat as text
|
|
366
407
|
i = start + 1;
|
|
367
408
|
textStart = start;
|
|
368
409
|
continue;
|
|
369
410
|
}
|
|
370
411
|
|
|
371
412
|
if (charAfter === '_') {
|
|
372
|
-
// {.class#id _temporary.field}
|
|
413
|
+
// {.class#id _temporary.field} or {.class _expr[...]}
|
|
373
414
|
i = afterSelectors + 1;
|
|
374
415
|
const nameStart = i;
|
|
375
416
|
while (i < input.length && /[\w.]/.test(input[i]!)) i++;
|
|
@@ -390,14 +431,31 @@ export function tokenize(input: string): Token[] {
|
|
|
390
431
|
textStart = i;
|
|
391
432
|
continue;
|
|
392
433
|
}
|
|
393
|
-
//
|
|
434
|
+
// Complex expression — scan for balanced closing }
|
|
435
|
+
const closeIdx_ = scanBalancedBrace(input, nameStart);
|
|
436
|
+
if (closeIdx_ !== -1) {
|
|
437
|
+
const expression = input.slice(afterSelectors, closeIdx_);
|
|
438
|
+
i = closeIdx_ + 1;
|
|
439
|
+
const token: ExpressionToken = {
|
|
440
|
+
type: 'expression',
|
|
441
|
+
expression,
|
|
442
|
+
start,
|
|
443
|
+
end: i,
|
|
444
|
+
};
|
|
445
|
+
if (className) token.className = className;
|
|
446
|
+
if (id) token.id = id;
|
|
447
|
+
tokens.push(token);
|
|
448
|
+
textStart = i;
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
451
|
+
// Unbalanced — treat as text
|
|
394
452
|
i = start + 1;
|
|
395
453
|
textStart = start;
|
|
396
454
|
continue;
|
|
397
455
|
}
|
|
398
456
|
|
|
399
457
|
if (charAfter === '@') {
|
|
400
|
-
// {.class#id @local.field}
|
|
458
|
+
// {.class#id @local.field} or {.class @expr[...]}
|
|
401
459
|
i = afterSelectors + 1;
|
|
402
460
|
const nameStart = i;
|
|
403
461
|
while (i < input.length && /[\w.]/.test(input[i]!)) i++;
|
|
@@ -418,7 +476,24 @@ export function tokenize(input: string): Token[] {
|
|
|
418
476
|
textStart = i;
|
|
419
477
|
continue;
|
|
420
478
|
}
|
|
421
|
-
//
|
|
479
|
+
// Complex expression — scan for balanced closing }
|
|
480
|
+
const closeIdx_at = scanBalancedBrace(input, nameStart);
|
|
481
|
+
if (closeIdx_at !== -1) {
|
|
482
|
+
const expression = input.slice(afterSelectors, closeIdx_at);
|
|
483
|
+
i = closeIdx_at + 1;
|
|
484
|
+
const token: ExpressionToken = {
|
|
485
|
+
type: 'expression',
|
|
486
|
+
expression,
|
|
487
|
+
start,
|
|
488
|
+
end: i,
|
|
489
|
+
};
|
|
490
|
+
if (className) token.className = className;
|
|
491
|
+
if (id) token.id = id;
|
|
492
|
+
tokens.push(token);
|
|
493
|
+
textStart = i;
|
|
494
|
+
continue;
|
|
495
|
+
}
|
|
496
|
+
// Unbalanced — treat as text
|
|
422
497
|
i = start + 1;
|
|
423
498
|
textStart = start;
|
|
424
499
|
continue;
|
|
@@ -468,7 +543,7 @@ export function tokenize(input: string): Token[] {
|
|
|
468
543
|
continue;
|
|
469
544
|
}
|
|
470
545
|
|
|
471
|
-
// {$variable} or {$variable.field.subfield}
|
|
546
|
+
// {$variable} or {$variable.field.subfield} or {$expr[...]}
|
|
472
547
|
if (nextChar === '$') {
|
|
473
548
|
flushText(i);
|
|
474
549
|
i += 2;
|
|
@@ -488,13 +563,27 @@ export function tokenize(input: string): Token[] {
|
|
|
488
563
|
textStart = i;
|
|
489
564
|
continue;
|
|
490
565
|
}
|
|
491
|
-
//
|
|
566
|
+
// Complex expression — scan for balanced closing }
|
|
567
|
+
const closeIdx = scanBalancedBrace(input, nameStart);
|
|
568
|
+
if (closeIdx !== -1) {
|
|
569
|
+
const expression = input.slice(start + 1, closeIdx);
|
|
570
|
+
i = closeIdx + 1;
|
|
571
|
+
tokens.push({
|
|
572
|
+
type: 'expression',
|
|
573
|
+
expression,
|
|
574
|
+
start,
|
|
575
|
+
end: i,
|
|
576
|
+
});
|
|
577
|
+
textStart = i;
|
|
578
|
+
continue;
|
|
579
|
+
}
|
|
580
|
+
// Unbalanced — treat as text
|
|
492
581
|
i = start + 1;
|
|
493
582
|
textStart = start;
|
|
494
583
|
continue;
|
|
495
584
|
}
|
|
496
585
|
|
|
497
|
-
// {_temporary.field}
|
|
586
|
+
// {_temporary.field} or {_expr[...]}
|
|
498
587
|
if (nextChar === '_') {
|
|
499
588
|
flushText(i);
|
|
500
589
|
i += 2;
|
|
@@ -514,13 +603,27 @@ export function tokenize(input: string): Token[] {
|
|
|
514
603
|
textStart = i;
|
|
515
604
|
continue;
|
|
516
605
|
}
|
|
517
|
-
//
|
|
606
|
+
// Complex expression — scan for balanced closing }
|
|
607
|
+
const closeIdx = scanBalancedBrace(input, nameStart);
|
|
608
|
+
if (closeIdx !== -1) {
|
|
609
|
+
const expression = input.slice(start + 1, closeIdx);
|
|
610
|
+
i = closeIdx + 1;
|
|
611
|
+
tokens.push({
|
|
612
|
+
type: 'expression',
|
|
613
|
+
expression,
|
|
614
|
+
start,
|
|
615
|
+
end: i,
|
|
616
|
+
});
|
|
617
|
+
textStart = i;
|
|
618
|
+
continue;
|
|
619
|
+
}
|
|
620
|
+
// Unbalanced — treat as text
|
|
518
621
|
i = start + 1;
|
|
519
622
|
textStart = start;
|
|
520
623
|
continue;
|
|
521
624
|
}
|
|
522
625
|
|
|
523
|
-
// {@local.field}
|
|
626
|
+
// {@local.field} or {@expr[...]}
|
|
524
627
|
if (nextChar === '@') {
|
|
525
628
|
flushText(i);
|
|
526
629
|
i += 2;
|
|
@@ -540,7 +643,21 @@ export function tokenize(input: string): Token[] {
|
|
|
540
643
|
textStart = i;
|
|
541
644
|
continue;
|
|
542
645
|
}
|
|
543
|
-
//
|
|
646
|
+
// Complex expression — scan for balanced closing }
|
|
647
|
+
const closeIdx = scanBalancedBrace(input, nameStart);
|
|
648
|
+
if (closeIdx !== -1) {
|
|
649
|
+
const expression = input.slice(start + 1, closeIdx);
|
|
650
|
+
i = closeIdx + 1;
|
|
651
|
+
tokens.push({
|
|
652
|
+
type: 'expression',
|
|
653
|
+
expression,
|
|
654
|
+
start,
|
|
655
|
+
end: i,
|
|
656
|
+
});
|
|
657
|
+
textStart = i;
|
|
658
|
+
continue;
|
|
659
|
+
}
|
|
660
|
+
// Unbalanced — treat as text
|
|
544
661
|
i = start + 1;
|
|
545
662
|
textStart = start;
|
|
546
663
|
continue;
|
package/src/store.ts
CHANGED
|
@@ -41,6 +41,7 @@ const SPECIAL_PASSAGES = new Set([
|
|
|
41
41
|
'StoryInit',
|
|
42
42
|
'StoryInterface',
|
|
43
43
|
'StoryVariables',
|
|
44
|
+
'StoryLoading',
|
|
44
45
|
'SaveTitle',
|
|
45
46
|
'PassageReady',
|
|
46
47
|
'PassageHeader',
|
|
@@ -233,6 +234,7 @@ export interface StoryState {
|
|
|
233
234
|
transitionConfig: TransitionConfig | null;
|
|
234
235
|
nextTransition: TransitionConfig | null;
|
|
235
236
|
nobr: boolean;
|
|
237
|
+
renderDeferred: boolean;
|
|
236
238
|
|
|
237
239
|
setMaxHistory: (limit: number) => void;
|
|
238
240
|
init: (
|
|
@@ -263,6 +265,8 @@ export interface StoryState {
|
|
|
263
265
|
setTransition: (config: TransitionConfig | null) => void;
|
|
264
266
|
setNextTransition: (config: TransitionConfig | null) => void;
|
|
265
267
|
consumeNextTransition: () => TransitionConfig | null;
|
|
268
|
+
deferRender: () => void;
|
|
269
|
+
clearDeferredRender: () => void;
|
|
266
270
|
}
|
|
267
271
|
|
|
268
272
|
export const useStoryStore = create<StoryState>()(
|
|
@@ -284,6 +288,7 @@ export const useStoryStore = create<StoryState>()(
|
|
|
284
288
|
transitionConfig: null,
|
|
285
289
|
nextTransition: null,
|
|
286
290
|
nobr: false,
|
|
291
|
+
renderDeferred: false,
|
|
287
292
|
|
|
288
293
|
setMaxHistory: (limit: number) => {
|
|
289
294
|
set((state) => {
|
|
@@ -510,6 +515,7 @@ export const useStoryStore = create<StoryState>()(
|
|
|
510
515
|
state.historyIndex = 0;
|
|
511
516
|
state.visitCounts = { [startPassage.name]: 1 };
|
|
512
517
|
state.renderCounts = { [startPassage.name]: 1 };
|
|
518
|
+
state.renderDeferred = false;
|
|
513
519
|
});
|
|
514
520
|
|
|
515
521
|
lastNavigationVars = get().variables;
|
|
@@ -776,5 +782,17 @@ export const useStoryStore = create<StoryState>()(
|
|
|
776
782
|
}
|
|
777
783
|
return current;
|
|
778
784
|
},
|
|
785
|
+
|
|
786
|
+
deferRender: () => {
|
|
787
|
+
set((state) => {
|
|
788
|
+
state.renderDeferred = true;
|
|
789
|
+
});
|
|
790
|
+
},
|
|
791
|
+
|
|
792
|
+
clearDeferredRender: () => {
|
|
793
|
+
set((state) => {
|
|
794
|
+
state.renderDeferred = false;
|
|
795
|
+
});
|
|
796
|
+
},
|
|
779
797
|
})),
|
|
780
798
|
);
|
package/src/story-api.ts
CHANGED
|
@@ -50,6 +50,24 @@ import type { TransitionConfig } from './transition';
|
|
|
50
50
|
export type { StoryAction };
|
|
51
51
|
export type { MacroMetadata };
|
|
52
52
|
|
|
53
|
+
// Deferred-render promise lifecycle.
|
|
54
|
+
// deferRender() creates a promise; ready() resolves it.
|
|
55
|
+
// index.tsx reads getReadyPromise() AFTER render(), which is after both
|
|
56
|
+
// author JS and storyinit have run, so it always gets the final promise.
|
|
57
|
+
let readyResolve: (() => void) | null = null;
|
|
58
|
+
let readyPromise: Promise<void> | null = null;
|
|
59
|
+
|
|
60
|
+
/** Returns the current deferred-render promise, or null if not deferred. */
|
|
61
|
+
export function getReadyPromise(): Promise<void> | null {
|
|
62
|
+
return readyPromise;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Test-only: reset module-level promise state. */
|
|
66
|
+
export function _resetReadyState(): void {
|
|
67
|
+
readyResolve = null;
|
|
68
|
+
readyPromise = null;
|
|
69
|
+
}
|
|
70
|
+
|
|
53
71
|
type NavigateCallback = (to: string, from: string) => void;
|
|
54
72
|
type StoryInitCallback = () => void;
|
|
55
73
|
type ActionsChangedCallback = () => void;
|
|
@@ -121,6 +139,8 @@ export interface StoryAPI {
|
|
|
121
139
|
setCSS(enabled: boolean): void;
|
|
122
140
|
setTransition(config: TransitionConfig | null): void;
|
|
123
141
|
setNextTransition(config: TransitionConfig | null): void;
|
|
142
|
+
deferRender(): void;
|
|
143
|
+
ready(): void;
|
|
124
144
|
random(): number;
|
|
125
145
|
randomInt(min: number, max: number): number;
|
|
126
146
|
readonly config: {
|
|
@@ -452,6 +472,21 @@ function createStoryAPI(): StoryAPI {
|
|
|
452
472
|
useStoryStore.getState().setNextTransition(config);
|
|
453
473
|
},
|
|
454
474
|
|
|
475
|
+
deferRender(): void {
|
|
476
|
+
useStoryStore.getState().deferRender();
|
|
477
|
+
readyPromise = new Promise<void>((resolve) => {
|
|
478
|
+
readyResolve = resolve;
|
|
479
|
+
});
|
|
480
|
+
},
|
|
481
|
+
|
|
482
|
+
ready(): void {
|
|
483
|
+
if (!readyResolve) return;
|
|
484
|
+
useStoryStore.getState().clearDeferredRender();
|
|
485
|
+
readyResolve();
|
|
486
|
+
readyResolve = null;
|
|
487
|
+
readyPromise = null;
|
|
488
|
+
},
|
|
489
|
+
|
|
455
490
|
random(): number {
|
|
456
491
|
return random();
|
|
457
492
|
},
|