@rohal12/spindle 0.7.0 → 0.9.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/package.json +3 -1
- package/src/components/macros/Button.tsx +13 -2
- package/src/components/macros/Computed.tsx +2 -2
- package/src/components/macros/Do.tsx +12 -2
- package/src/components/macros/For.tsx +52 -12
- package/src/components/macros/Goto.tsx +5 -3
- package/src/components/macros/If.tsx +7 -2
- package/src/components/macros/Include.tsx +3 -3
- package/src/components/macros/MacroLink.tsx +20 -5
- package/src/components/macros/Meter.tsx +7 -3
- package/src/components/macros/Print.tsx +2 -2
- package/src/components/macros/Set.tsx +16 -3
- package/src/components/macros/Switch.tsx +11 -4
- package/src/components/macros/VarDisplay.tsx +14 -6
- package/src/components/macros/Widget.tsx +1 -3
- package/src/components/macros/WidgetInvocation.tsx +39 -9
- package/src/expression.ts +10 -4
- package/src/hooks/use-merged-locals.ts +12 -10
- package/src/markup/ast.ts +1 -1
- package/src/markup/render.tsx +11 -1
- package/src/markup/tokenizer.ts +55 -1
- package/src/story-variables.ts +5 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rohal12/spindle",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "A Preact-based story format for Twine 2.",
|
|
6
6
|
"license": "Unlicense",
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
"compile": "bun run scripts/compile-story.ts",
|
|
39
39
|
"preview": "bun run build && bun run compile",
|
|
40
40
|
"test": "vitest run",
|
|
41
|
+
"test:coverage": "vitest run --coverage",
|
|
41
42
|
"test:watch": "vitest",
|
|
42
43
|
"test:e2e": "vitest run --config vitest.e2e.config.ts",
|
|
43
44
|
"typecheck": "tsc --noEmit",
|
|
@@ -64,6 +65,7 @@
|
|
|
64
65
|
"@preact/preset-vite": "^2.10.3",
|
|
65
66
|
"@rohal12/twee-ts": "^1.1.2",
|
|
66
67
|
"@types/js-yaml": "^4.0.9",
|
|
68
|
+
"@types/react": "^18.3.28",
|
|
67
69
|
"@vitest/coverage-v8": "^4.0.18",
|
|
68
70
|
"happy-dom": "^20.8.3",
|
|
69
71
|
"husky": "^9.1.7",
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
import { useContext } from 'preact/hooks';
|
|
1
2
|
import { useStoryStore } from '../../store';
|
|
2
3
|
import { execute } from '../../expression';
|
|
3
|
-
import { renderInlineNodes } from '../../markup/render';
|
|
4
|
+
import { renderInlineNodes, LocalsContext } from '../../markup/render';
|
|
4
5
|
import { deepClone } from '../../class-registry';
|
|
5
6
|
import { collectText } from '../../utils/extract-text';
|
|
6
7
|
import { useAction } from '../../hooks/use-action';
|
|
8
|
+
import { useMergedLocals } from '../../hooks/use-merged-locals';
|
|
7
9
|
import type { ASTNode } from '../../markup/ast';
|
|
8
10
|
|
|
9
11
|
interface ButtonProps {
|
|
@@ -14,13 +16,17 @@ interface ButtonProps {
|
|
|
14
16
|
}
|
|
15
17
|
|
|
16
18
|
export function Button({ rawArgs, children, className, id }: ButtonProps) {
|
|
19
|
+
const scope = useContext(LocalsContext);
|
|
20
|
+
const [, , mergedLocals] = useMergedLocals();
|
|
21
|
+
|
|
17
22
|
const handleClick = () => {
|
|
18
23
|
const state = useStoryStore.getState();
|
|
19
24
|
const vars = deepClone(state.variables);
|
|
20
25
|
const temps = deepClone(state.temporary);
|
|
26
|
+
const localsClone = { ...mergedLocals };
|
|
21
27
|
|
|
22
28
|
try {
|
|
23
|
-
execute(rawArgs, vars, temps);
|
|
29
|
+
execute(rawArgs, vars, temps, localsClone);
|
|
24
30
|
} catch (err) {
|
|
25
31
|
console.error(`spindle: Error in {button ${rawArgs}}:`, err);
|
|
26
32
|
return;
|
|
@@ -36,6 +42,11 @@ export function Button({ rawArgs, children, className, id }: ButtonProps) {
|
|
|
36
42
|
state.setTemporary(key, temps[key]);
|
|
37
43
|
}
|
|
38
44
|
}
|
|
45
|
+
for (const key of Object.keys(localsClone)) {
|
|
46
|
+
if (localsClone[key] !== mergedLocals[key]) {
|
|
47
|
+
scope.update(`@${key}`, localsClone[key]);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
39
50
|
};
|
|
40
51
|
|
|
41
52
|
useAction({
|
|
@@ -59,7 +59,7 @@ function valuesEqual(a: unknown, b: unknown): boolean {
|
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
export function Computed({ rawArgs }: ComputedProps) {
|
|
62
|
-
const [mergedVars, mergedTemps] = useMergedLocals();
|
|
62
|
+
const [mergedVars, mergedTemps, mergedLocals] = useMergedLocals();
|
|
63
63
|
|
|
64
64
|
let target: string;
|
|
65
65
|
let expr: string;
|
|
@@ -84,7 +84,7 @@ export function Computed({ rawArgs }: ComputedProps) {
|
|
|
84
84
|
|
|
85
85
|
let newValue: unknown;
|
|
86
86
|
try {
|
|
87
|
-
newValue = evaluate(expr, mergedVars, mergedTemps);
|
|
87
|
+
newValue = evaluate(expr, mergedVars, mergedTemps, mergedLocals);
|
|
88
88
|
} catch (err) {
|
|
89
89
|
console.error(`spindle: Error in {computed ${rawArgs}}:`, err);
|
|
90
90
|
return;
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import { useLayoutEffect } from 'preact/hooks';
|
|
1
|
+
import { useLayoutEffect, useContext } from 'preact/hooks';
|
|
2
2
|
import { useStoryStore } from '../../store';
|
|
3
3
|
import { execute } from '../../expression';
|
|
4
4
|
import type { ASTNode } from '../../markup/ast';
|
|
5
5
|
import { deepClone } from '../../class-registry';
|
|
6
|
+
import { LocalsContext } from '../../markup/render';
|
|
7
|
+
import { useMergedLocals } from '../../hooks/use-merged-locals';
|
|
6
8
|
|
|
7
9
|
interface DoProps {
|
|
8
10
|
children: ASTNode[];
|
|
@@ -17,14 +19,17 @@ function collectText(nodes: ASTNode[]): string {
|
|
|
17
19
|
|
|
18
20
|
export function Do({ children }: DoProps) {
|
|
19
21
|
const code = collectText(children);
|
|
22
|
+
const scope = useContext(LocalsContext);
|
|
23
|
+
const [, , mergedLocals] = useMergedLocals();
|
|
20
24
|
|
|
21
25
|
useLayoutEffect(() => {
|
|
22
26
|
const state = useStoryStore.getState();
|
|
23
27
|
const vars = deepClone(state.variables);
|
|
24
28
|
const temps = deepClone(state.temporary);
|
|
29
|
+
const localsClone = { ...mergedLocals };
|
|
25
30
|
|
|
26
31
|
try {
|
|
27
|
-
execute(code, vars, temps);
|
|
32
|
+
execute(code, vars, temps, localsClone);
|
|
28
33
|
} catch (err) {
|
|
29
34
|
console.error(`spindle: Error in {do}:`, err);
|
|
30
35
|
return;
|
|
@@ -41,6 +46,11 @@ export function Do({ children }: DoProps) {
|
|
|
41
46
|
state.setTemporary(key, temps[key]);
|
|
42
47
|
}
|
|
43
48
|
}
|
|
49
|
+
for (const key of Object.keys(localsClone)) {
|
|
50
|
+
if (localsClone[key] !== mergedLocals[key]) {
|
|
51
|
+
scope.update(`@${key}`, localsClone[key]);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
44
54
|
}, []);
|
|
45
55
|
|
|
46
56
|
return null;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { useContext } from 'preact/hooks';
|
|
1
|
+
import { useContext, useState, useCallback } from 'preact/hooks';
|
|
2
2
|
import { evaluate } from '../../expression';
|
|
3
3
|
import { LocalsContext, renderNodes } from '../../markup/render';
|
|
4
|
+
import type { LocalsScope } from '../../markup/render';
|
|
4
5
|
import { useMergedLocals } from '../../hooks/use-merged-locals';
|
|
5
6
|
import type { ASTNode } from '../../markup/ast';
|
|
6
7
|
|
|
@@ -12,7 +13,7 @@ interface ForProps {
|
|
|
12
13
|
}
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
|
-
* Parse for-loop args: "
|
|
16
|
+
* Parse for-loop args: "@item, @i of $list" or "@item of $list"
|
|
16
17
|
*/
|
|
17
18
|
function parseForArgs(rawArgs: string): {
|
|
18
19
|
itemVar: string;
|
|
@@ -31,12 +32,51 @@ function parseForArgs(rawArgs: string): {
|
|
|
31
32
|
const itemVar = vars[0]!;
|
|
32
33
|
const indexVar = vars.length > 1 ? vars[1]! : null;
|
|
33
34
|
|
|
35
|
+
if (!itemVar.startsWith('@')) {
|
|
36
|
+
throw new Error(`{for} loop variable must use @ prefix: got "${itemVar}"`);
|
|
37
|
+
}
|
|
38
|
+
if (indexVar && !indexVar.startsWith('@')) {
|
|
39
|
+
throw new Error(
|
|
40
|
+
`{for} index variable must use @ prefix: got "${indexVar}"`,
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
34
44
|
return { itemVar, indexVar, listExpr };
|
|
35
45
|
}
|
|
36
46
|
|
|
47
|
+
function ForIteration({
|
|
48
|
+
parentValues,
|
|
49
|
+
ownKeys,
|
|
50
|
+
initialValues,
|
|
51
|
+
children,
|
|
52
|
+
}: {
|
|
53
|
+
parentValues: Record<string, unknown>;
|
|
54
|
+
ownKeys: Record<string, unknown>;
|
|
55
|
+
initialValues: Record<string, unknown>;
|
|
56
|
+
children: ASTNode[];
|
|
57
|
+
}) {
|
|
58
|
+
const [localState, setLocalState] = useState<Record<string, unknown>>(() => ({
|
|
59
|
+
...parentValues,
|
|
60
|
+
...ownKeys,
|
|
61
|
+
...initialValues,
|
|
62
|
+
}));
|
|
63
|
+
|
|
64
|
+
const update = useCallback((key: string, value: unknown) => {
|
|
65
|
+
setLocalState((prev) => ({ ...prev, [key]: value }));
|
|
66
|
+
}, []);
|
|
67
|
+
|
|
68
|
+
const scope: LocalsScope = { values: localState, update };
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<LocalsContext.Provider value={scope}>
|
|
72
|
+
{renderNodes(children)}
|
|
73
|
+
</LocalsContext.Provider>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
37
77
|
export function For({ rawArgs, children, className, id }: ForProps) {
|
|
38
|
-
const
|
|
39
|
-
const [mergedVars, mergedTemps] = useMergedLocals();
|
|
78
|
+
const parentScope = useContext(LocalsContext);
|
|
79
|
+
const [mergedVars, mergedTemps, mergedLocals] = useMergedLocals();
|
|
40
80
|
|
|
41
81
|
let parsed: ReturnType<typeof parseForArgs>;
|
|
42
82
|
try {
|
|
@@ -56,7 +96,7 @@ export function For({ rawArgs, children, className, id }: ForProps) {
|
|
|
56
96
|
|
|
57
97
|
let list: unknown[];
|
|
58
98
|
try {
|
|
59
|
-
const result = evaluate(listExpr, mergedVars, mergedTemps);
|
|
99
|
+
const result = evaluate(listExpr, mergedVars, mergedTemps, mergedLocals);
|
|
60
100
|
if (!Array.isArray(result)) {
|
|
61
101
|
return (
|
|
62
102
|
<span class="error">
|
|
@@ -77,19 +117,19 @@ export function For({ rawArgs, children, className, id }: ForProps) {
|
|
|
77
117
|
}
|
|
78
118
|
|
|
79
119
|
const content = list.map((item, i) => {
|
|
80
|
-
const
|
|
81
|
-
...parentLocals,
|
|
120
|
+
const ownKeys: Record<string, unknown> = {
|
|
82
121
|
[itemVar]: item,
|
|
83
122
|
...(indexVar ? { [indexVar]: i } : undefined),
|
|
84
123
|
};
|
|
85
124
|
|
|
86
125
|
return (
|
|
87
|
-
<
|
|
126
|
+
<ForIteration
|
|
88
127
|
key={i}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
{
|
|
92
|
-
|
|
128
|
+
parentValues={parentScope.values}
|
|
129
|
+
ownKeys={ownKeys}
|
|
130
|
+
initialValues={{}}
|
|
131
|
+
children={children}
|
|
132
|
+
/>
|
|
93
133
|
);
|
|
94
134
|
});
|
|
95
135
|
|
|
@@ -1,22 +1,24 @@
|
|
|
1
1
|
import { useLayoutEffect } from 'preact/hooks';
|
|
2
2
|
import { useStoryStore } from '../../store';
|
|
3
3
|
import { evaluate } from '../../expression';
|
|
4
|
+
import { useMergedLocals } from '../../hooks/use-merged-locals';
|
|
4
5
|
|
|
5
6
|
interface GotoProps {
|
|
6
7
|
rawArgs: string;
|
|
7
8
|
}
|
|
8
9
|
|
|
9
10
|
export function Goto({ rawArgs }: GotoProps) {
|
|
11
|
+
const [variables, temporary, locals] = useMergedLocals();
|
|
12
|
+
|
|
10
13
|
useLayoutEffect(() => {
|
|
11
|
-
const state = useStoryStore.getState();
|
|
12
14
|
let passageName: string;
|
|
13
15
|
try {
|
|
14
|
-
const result = evaluate(rawArgs,
|
|
16
|
+
const result = evaluate(rawArgs, variables, temporary, locals);
|
|
15
17
|
passageName = String(result);
|
|
16
18
|
} catch {
|
|
17
19
|
passageName = rawArgs.replace(/^["']|["']$/g, '');
|
|
18
20
|
}
|
|
19
|
-
|
|
21
|
+
useStoryStore.getState().navigate(passageName);
|
|
20
22
|
}, []);
|
|
21
23
|
|
|
22
24
|
return null;
|
|
@@ -8,7 +8,7 @@ interface IfProps {
|
|
|
8
8
|
}
|
|
9
9
|
|
|
10
10
|
export function If({ branches }: IfProps) {
|
|
11
|
-
const [mergedVars, mergedTemps] = useMergedLocals();
|
|
11
|
+
const [mergedVars, mergedTemps, mergedLocals] = useMergedLocals();
|
|
12
12
|
|
|
13
13
|
function renderBranch(branch: Branch) {
|
|
14
14
|
const children = renderNodes(branch.children);
|
|
@@ -31,7 +31,12 @@ export function If({ branches }: IfProps) {
|
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
try {
|
|
34
|
-
const result = evaluate(
|
|
34
|
+
const result = evaluate(
|
|
35
|
+
branch.rawArgs,
|
|
36
|
+
mergedVars,
|
|
37
|
+
mergedTemps,
|
|
38
|
+
mergedLocals,
|
|
39
|
+
);
|
|
35
40
|
if (result) {
|
|
36
41
|
return renderBranch(branch);
|
|
37
42
|
}
|
|
@@ -3,6 +3,7 @@ import { evaluate } from '../../expression';
|
|
|
3
3
|
import { tokenize } from '../../markup/tokenizer';
|
|
4
4
|
import { buildAST } from '../../markup/ast';
|
|
5
5
|
import { renderNodes } from '../../markup/render';
|
|
6
|
+
import { useMergedLocals } from '../../hooks/use-merged-locals';
|
|
6
7
|
|
|
7
8
|
interface IncludeProps {
|
|
8
9
|
rawArgs: string;
|
|
@@ -12,14 +13,13 @@ interface IncludeProps {
|
|
|
12
13
|
|
|
13
14
|
export function Include({ rawArgs, className, id }: IncludeProps) {
|
|
14
15
|
const storyData = useStoryStore((s) => s.storyData);
|
|
15
|
-
const variables =
|
|
16
|
-
const temporary = useStoryStore((s) => s.temporary);
|
|
16
|
+
const [variables, temporary, locals] = useMergedLocals();
|
|
17
17
|
|
|
18
18
|
if (!storyData) return null;
|
|
19
19
|
|
|
20
20
|
let passageName: string;
|
|
21
21
|
try {
|
|
22
|
-
const result = evaluate(rawArgs, variables, temporary);
|
|
22
|
+
const result = evaluate(rawArgs, variables, temporary, locals);
|
|
23
23
|
passageName = String(result);
|
|
24
24
|
} catch {
|
|
25
25
|
passageName = rawArgs.replace(/^["']|["']$/g, '');
|
|
@@ -1,7 +1,10 @@
|
|
|
1
|
+
import { useContext } from 'preact/hooks';
|
|
1
2
|
import { useStoryStore } from '../../store';
|
|
2
3
|
import { execute } from '../../expression';
|
|
3
4
|
import type { ASTNode } from '../../markup/ast';
|
|
4
5
|
import { deepClone } from '../../class-registry';
|
|
6
|
+
import { LocalsContext } from '../../markup/render';
|
|
7
|
+
import { useMergedLocals } from '../../hooks/use-merged-locals';
|
|
5
8
|
|
|
6
9
|
interface MacroLinkProps {
|
|
7
10
|
rawArgs: string;
|
|
@@ -37,23 +40,28 @@ import { useAction } from '../../hooks/use-action';
|
|
|
37
40
|
/**
|
|
38
41
|
* Execute the children imperatively: walk AST for {set} and {do} macros.
|
|
39
42
|
*/
|
|
40
|
-
function executeChildren(
|
|
43
|
+
function executeChildren(
|
|
44
|
+
children: ASTNode[],
|
|
45
|
+
mergedLocals: Record<string, unknown>,
|
|
46
|
+
scopeUpdate: (key: string, value: unknown) => void,
|
|
47
|
+
) {
|
|
41
48
|
const state = useStoryStore.getState();
|
|
42
49
|
const vars = deepClone(state.variables);
|
|
43
50
|
const temps = deepClone(state.temporary);
|
|
51
|
+
const localsClone = { ...mergedLocals };
|
|
44
52
|
|
|
45
53
|
for (const node of children) {
|
|
46
54
|
if (node.type !== 'macro') continue;
|
|
47
55
|
if (node.name === 'set') {
|
|
48
56
|
try {
|
|
49
|
-
execute(node.rawArgs, vars, temps);
|
|
57
|
+
execute(node.rawArgs, vars, temps, localsClone);
|
|
50
58
|
} catch (err) {
|
|
51
59
|
console.error(`spindle: Error in {link} child {set}:`, err);
|
|
52
60
|
}
|
|
53
61
|
} else if (node.name === 'do') {
|
|
54
62
|
const code = collectText(node.children);
|
|
55
63
|
try {
|
|
56
|
-
execute(code, vars, temps);
|
|
64
|
+
execute(code, vars, temps, localsClone);
|
|
57
65
|
} catch (err) {
|
|
58
66
|
console.error(`spindle: Error in {link} child {do}:`, err);
|
|
59
67
|
}
|
|
@@ -71,6 +79,11 @@ function executeChildren(children: ASTNode[]) {
|
|
|
71
79
|
state.setTemporary(key, temps[key]);
|
|
72
80
|
}
|
|
73
81
|
}
|
|
82
|
+
for (const key of Object.keys(localsClone)) {
|
|
83
|
+
if (localsClone[key] !== mergedLocals[key]) {
|
|
84
|
+
scopeUpdate(`@${key}`, localsClone[key]);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
74
87
|
}
|
|
75
88
|
|
|
76
89
|
export function MacroLink({
|
|
@@ -80,10 +93,12 @@ export function MacroLink({
|
|
|
80
93
|
id,
|
|
81
94
|
}: MacroLinkProps) {
|
|
82
95
|
const { display, passage } = parseArgs(rawArgs);
|
|
96
|
+
const scope = useContext(LocalsContext);
|
|
97
|
+
const [, , mergedLocals] = useMergedLocals();
|
|
83
98
|
|
|
84
99
|
const handleClick = (e: Event) => {
|
|
85
100
|
e.preventDefault();
|
|
86
|
-
executeChildren(children);
|
|
101
|
+
executeChildren(children, mergedLocals, scope.update);
|
|
87
102
|
if (passage) {
|
|
88
103
|
useStoryStore.getState().navigate(passage);
|
|
89
104
|
}
|
|
@@ -96,7 +111,7 @@ export function MacroLink({
|
|
|
96
111
|
label: display,
|
|
97
112
|
target: passage ?? undefined,
|
|
98
113
|
perform: () => {
|
|
99
|
-
executeChildren(children);
|
|
114
|
+
executeChildren(children, mergedLocals, scope.update);
|
|
100
115
|
if (passage) {
|
|
101
116
|
useStoryStore.getState().navigate(passage);
|
|
102
117
|
}
|
|
@@ -83,12 +83,16 @@ function formatLabel(
|
|
|
83
83
|
}
|
|
84
84
|
|
|
85
85
|
export function Meter({ rawArgs, className, id }: MeterProps) {
|
|
86
|
-
const [mergedVars, mergedTemps] = useMergedLocals();
|
|
86
|
+
const [mergedVars, mergedTemps, mergedLocals] = useMergedLocals();
|
|
87
87
|
|
|
88
88
|
try {
|
|
89
89
|
const { currentExpr, maxExpr, labelMode } = parseArgs(rawArgs);
|
|
90
|
-
const current = Number(
|
|
91
|
-
|
|
90
|
+
const current = Number(
|
|
91
|
+
evaluate(currentExpr, mergedVars, mergedTemps, mergedLocals),
|
|
92
|
+
);
|
|
93
|
+
const max = Number(
|
|
94
|
+
evaluate(maxExpr, mergedVars, mergedTemps, mergedLocals),
|
|
95
|
+
);
|
|
92
96
|
const pct =
|
|
93
97
|
max === 0 ? 0 : Math.max(0, Math.min(100, (current / max) * 100));
|
|
94
98
|
const label = formatLabel(current, max, labelMode);
|
|
@@ -8,10 +8,10 @@ interface PrintProps {
|
|
|
8
8
|
}
|
|
9
9
|
|
|
10
10
|
export function Print({ rawArgs, className, id }: PrintProps) {
|
|
11
|
-
const [mergedVars, mergedTemps] = useMergedLocals();
|
|
11
|
+
const [mergedVars, mergedTemps, mergedLocals] = useMergedLocals();
|
|
12
12
|
|
|
13
13
|
try {
|
|
14
|
-
const result = evaluate(rawArgs, mergedVars, mergedTemps);
|
|
14
|
+
const result = evaluate(rawArgs, mergedVars, mergedTemps, mergedLocals);
|
|
15
15
|
const display = result == null ? '' : String(result);
|
|
16
16
|
if (className || id)
|
|
17
17
|
return (
|
|
@@ -1,26 +1,32 @@
|
|
|
1
|
-
import { useLayoutEffect } from 'preact/hooks';
|
|
1
|
+
import { useLayoutEffect, useContext } from 'preact/hooks';
|
|
2
2
|
import { useStoryStore } from '../../store';
|
|
3
3
|
import { execute } from '../../expression';
|
|
4
4
|
import { deepClone } from '../../class-registry';
|
|
5
|
+
import { LocalsContext } from '../../markup/render';
|
|
6
|
+
import { useMergedLocals } from '../../hooks/use-merged-locals';
|
|
5
7
|
|
|
6
8
|
interface SetProps {
|
|
7
9
|
rawArgs: string;
|
|
8
10
|
}
|
|
9
11
|
|
|
10
12
|
export function Set({ rawArgs }: SetProps) {
|
|
13
|
+
const scope = useContext(LocalsContext);
|
|
14
|
+
const [, , mergedLocals] = useMergedLocals();
|
|
15
|
+
|
|
11
16
|
useLayoutEffect(() => {
|
|
12
17
|
const state = useStoryStore.getState();
|
|
13
18
|
const vars = deepClone(state.variables);
|
|
14
19
|
const temps = deepClone(state.temporary);
|
|
20
|
+
const localsClone = { ...mergedLocals };
|
|
15
21
|
|
|
16
22
|
try {
|
|
17
|
-
execute(rawArgs, vars, temps);
|
|
23
|
+
execute(rawArgs, vars, temps, localsClone);
|
|
18
24
|
} catch (err) {
|
|
19
25
|
console.error(`spindle: Error in {set ${rawArgs}}:`, err);
|
|
20
26
|
return;
|
|
21
27
|
}
|
|
22
28
|
|
|
23
|
-
// Diff and apply changes
|
|
29
|
+
// Diff and apply store changes
|
|
24
30
|
for (const key of Object.keys(vars)) {
|
|
25
31
|
if (vars[key] !== state.variables[key]) {
|
|
26
32
|
state.setVariable(key, vars[key]);
|
|
@@ -31,6 +37,13 @@ export function Set({ rawArgs }: SetProps) {
|
|
|
31
37
|
state.setTemporary(key, temps[key]);
|
|
32
38
|
}
|
|
33
39
|
}
|
|
40
|
+
|
|
41
|
+
// Diff and apply locals changes
|
|
42
|
+
for (const key of Object.keys(localsClone)) {
|
|
43
|
+
if (localsClone[key] !== mergedLocals[key]) {
|
|
44
|
+
scope.update(`@${key}`, localsClone[key]);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
34
47
|
}, []);
|
|
35
48
|
|
|
36
49
|
return null;
|
|
@@ -9,11 +9,11 @@ interface SwitchProps {
|
|
|
9
9
|
}
|
|
10
10
|
|
|
11
11
|
export function Switch({ rawArgs, branches }: SwitchProps) {
|
|
12
|
-
const [mergedVars, mergedTemps] = useMergedLocals();
|
|
12
|
+
const [mergedVars, mergedTemps, mergedLocals] = useMergedLocals();
|
|
13
13
|
|
|
14
14
|
let switchValue: unknown;
|
|
15
15
|
try {
|
|
16
|
-
switchValue = evaluate(rawArgs, mergedVars, mergedTemps);
|
|
16
|
+
switchValue = evaluate(rawArgs, mergedVars, mergedTemps, mergedLocals);
|
|
17
17
|
} catch (err) {
|
|
18
18
|
return (
|
|
19
19
|
<span
|
|
@@ -26,8 +26,10 @@ export function Switch({ rawArgs, branches }: SwitchProps) {
|
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
// Find matching {case} branch or {default}
|
|
29
|
+
// Skip first branch (index 0) — it holds the switch expression, not a case
|
|
29
30
|
let defaultBranch: Branch | null = null;
|
|
30
|
-
for (
|
|
31
|
+
for (let i = 1; i < branches.length; i++) {
|
|
32
|
+
const branch = branches[i]!;
|
|
31
33
|
// {default} has empty rawArgs
|
|
32
34
|
if (branch.rawArgs === '') {
|
|
33
35
|
defaultBranch = branch;
|
|
@@ -35,7 +37,12 @@ export function Switch({ rawArgs, branches }: SwitchProps) {
|
|
|
35
37
|
}
|
|
36
38
|
|
|
37
39
|
try {
|
|
38
|
-
const caseValue = evaluate(
|
|
40
|
+
const caseValue = evaluate(
|
|
41
|
+
branch.rawArgs,
|
|
42
|
+
mergedVars,
|
|
43
|
+
mergedTemps,
|
|
44
|
+
mergedLocals,
|
|
45
|
+
);
|
|
39
46
|
if (switchValue === caseValue) {
|
|
40
47
|
return <>{renderNodes(branch.children)}</>;
|
|
41
48
|
}
|
|
@@ -4,22 +4,30 @@ import { LocalsContext } from '../../markup/render';
|
|
|
4
4
|
|
|
5
5
|
interface VarDisplayProps {
|
|
6
6
|
name: string;
|
|
7
|
-
scope: 'variable' | 'temporary';
|
|
7
|
+
scope: 'variable' | 'temporary' | 'local';
|
|
8
8
|
className?: string;
|
|
9
9
|
id?: string;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
export function VarDisplay({ name, scope, className, id }: VarDisplayProps) {
|
|
13
|
-
const
|
|
13
|
+
const localsScope = useContext(LocalsContext);
|
|
14
14
|
const parts = name.split('.');
|
|
15
15
|
const root = parts[0]!;
|
|
16
16
|
const storeValue = useStoryStore((s) =>
|
|
17
|
-
scope === 'variable'
|
|
17
|
+
scope === 'variable'
|
|
18
|
+
? s.variables[root]
|
|
19
|
+
: scope === 'temporary'
|
|
20
|
+
? s.temporary[root]
|
|
21
|
+
: undefined,
|
|
18
22
|
);
|
|
19
23
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
24
|
+
let value: unknown;
|
|
25
|
+
if (scope === 'local') {
|
|
26
|
+
const key = `@${root}`;
|
|
27
|
+
value = key in localsScope.values ? localsScope.values[key] : undefined;
|
|
28
|
+
} else {
|
|
29
|
+
value = storeValue;
|
|
30
|
+
}
|
|
23
31
|
|
|
24
32
|
// Resolve dot path (e.g. "character.name" → character['name'])
|
|
25
33
|
for (let i = 1; i < parts.length; i++) {
|
|
@@ -13,9 +13,7 @@ interface WidgetProps {
|
|
|
13
13
|
function parseWidgetDef(rawArgs: string): { name: string; params: string[] } {
|
|
14
14
|
const tokens = rawArgs.trim().split(/\s+/);
|
|
15
15
|
const name = tokens[0]!.replace(/["']/g, '');
|
|
16
|
-
const params = tokens
|
|
17
|
-
.slice(1)
|
|
18
|
-
.filter((t) => t.startsWith('$') || t.startsWith('_'));
|
|
16
|
+
const params = tokens.slice(1).filter((t) => t.startsWith('@'));
|
|
19
17
|
return { name, params };
|
|
20
18
|
}
|
|
21
19
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { useContext } from 'preact/hooks';
|
|
1
|
+
import { useContext, useState, useCallback } from 'preact/hooks';
|
|
2
2
|
import { LocalsContext, renderNodes } from '../../markup/render';
|
|
3
|
+
import type { LocalsScope } from '../../markup/render';
|
|
3
4
|
import { useMergedLocals } from '../../hooks/use-merged-locals';
|
|
4
5
|
import { evaluate } from '../../expression';
|
|
5
6
|
import type { ASTNode } from '../../markup/ast';
|
|
@@ -60,20 +61,47 @@ function splitArgs(raw: string): string[] {
|
|
|
60
61
|
return args;
|
|
61
62
|
}
|
|
62
63
|
|
|
64
|
+
function WidgetBody({
|
|
65
|
+
body,
|
|
66
|
+
parentValues,
|
|
67
|
+
ownKeys,
|
|
68
|
+
}: {
|
|
69
|
+
body: ASTNode[];
|
|
70
|
+
parentValues: Record<string, unknown>;
|
|
71
|
+
ownKeys: Record<string, unknown>;
|
|
72
|
+
}) {
|
|
73
|
+
const [localState, setLocalState] = useState<Record<string, unknown>>(() => ({
|
|
74
|
+
...parentValues,
|
|
75
|
+
...ownKeys,
|
|
76
|
+
}));
|
|
77
|
+
|
|
78
|
+
const update = useCallback((key: string, value: unknown) => {
|
|
79
|
+
setLocalState((prev) => ({ ...prev, [key]: value }));
|
|
80
|
+
}, []);
|
|
81
|
+
|
|
82
|
+
const scope: LocalsScope = { values: localState, update };
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<LocalsContext.Provider value={scope}>
|
|
86
|
+
{renderNodes(body)}
|
|
87
|
+
</LocalsContext.Provider>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
63
91
|
export function WidgetInvocation({
|
|
64
92
|
body,
|
|
65
93
|
params,
|
|
66
94
|
rawArgs,
|
|
67
95
|
}: WidgetInvocationProps) {
|
|
68
|
-
const
|
|
69
|
-
const [mergedVars, mergedTemps] = useMergedLocals();
|
|
96
|
+
const parentScope = useContext(LocalsContext);
|
|
97
|
+
const [mergedVars, mergedTemps, mergedLocals] = useMergedLocals();
|
|
70
98
|
|
|
71
99
|
if (params.length === 0 || !rawArgs) {
|
|
72
100
|
return <>{renderNodes(body)}</>;
|
|
73
101
|
}
|
|
74
102
|
|
|
75
103
|
const argExprs = splitArgs(rawArgs);
|
|
76
|
-
const
|
|
104
|
+
const ownKeys: Record<string, unknown> = {};
|
|
77
105
|
|
|
78
106
|
for (let i = 0; i < params.length; i++) {
|
|
79
107
|
const param = params[i]!;
|
|
@@ -81,17 +109,19 @@ export function WidgetInvocation({
|
|
|
81
109
|
let value: unknown;
|
|
82
110
|
if (expr !== undefined) {
|
|
83
111
|
try {
|
|
84
|
-
value = evaluate(expr, mergedVars, mergedTemps);
|
|
112
|
+
value = evaluate(expr, mergedVars, mergedTemps, mergedLocals);
|
|
85
113
|
} catch {
|
|
86
114
|
value = undefined;
|
|
87
115
|
}
|
|
88
116
|
}
|
|
89
|
-
|
|
117
|
+
ownKeys[param] = value;
|
|
90
118
|
}
|
|
91
119
|
|
|
92
120
|
return (
|
|
93
|
-
<
|
|
94
|
-
{
|
|
95
|
-
|
|
121
|
+
<WidgetBody
|
|
122
|
+
body={body}
|
|
123
|
+
parentValues={parentScope.values}
|
|
124
|
+
ownKeys={ownKeys}
|
|
125
|
+
/>
|
|
96
126
|
);
|
|
97
127
|
}
|