@rohal12/spindle 0.1.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/README.md +66 -0
- package/dist/pkg/format.js +1 -0
- package/dist/pkg/index.js +12 -0
- package/dist/pkg/types/globals.d.ts +18 -0
- package/dist/pkg/types/index.d.ts +158 -0
- package/package.json +71 -0
- package/src/components/App.tsx +53 -0
- package/src/components/Passage.tsx +36 -0
- package/src/components/PassageLink.tsx +35 -0
- package/src/components/SaveLoadDialog.tsx +403 -0
- package/src/components/SettingsDialog.tsx +106 -0
- package/src/components/StoryInterface.tsx +31 -0
- package/src/components/macros/Back.tsx +23 -0
- package/src/components/macros/Button.tsx +49 -0
- package/src/components/macros/Checkbox.tsx +41 -0
- package/src/components/macros/Computed.tsx +100 -0
- package/src/components/macros/Cycle.tsx +39 -0
- package/src/components/macros/Do.tsx +46 -0
- package/src/components/macros/For.tsx +113 -0
- package/src/components/macros/Forward.tsx +25 -0
- package/src/components/macros/Goto.tsx +23 -0
- package/src/components/macros/If.tsx +63 -0
- package/src/components/macros/Include.tsx +52 -0
- package/src/components/macros/Listbox.tsx +42 -0
- package/src/components/macros/MacroLink.tsx +107 -0
- package/src/components/macros/Numberbox.tsx +43 -0
- package/src/components/macros/Print.tsx +48 -0
- package/src/components/macros/QuickLoad.tsx +33 -0
- package/src/components/macros/QuickSave.tsx +22 -0
- package/src/components/macros/Radiobutton.tsx +59 -0
- package/src/components/macros/Repeat.tsx +53 -0
- package/src/components/macros/Restart.tsx +27 -0
- package/src/components/macros/Saves.tsx +25 -0
- package/src/components/macros/Set.tsx +36 -0
- package/src/components/macros/SettingsButton.tsx +29 -0
- package/src/components/macros/Stop.tsx +12 -0
- package/src/components/macros/StoryTitle.tsx +20 -0
- package/src/components/macros/Switch.tsx +69 -0
- package/src/components/macros/Textarea.tsx +41 -0
- package/src/components/macros/Textbox.tsx +40 -0
- package/src/components/macros/Timed.tsx +63 -0
- package/src/components/macros/Type.tsx +83 -0
- package/src/components/macros/Unset.tsx +25 -0
- package/src/components/macros/VarDisplay.tsx +44 -0
- package/src/components/macros/Widget.tsx +18 -0
- package/src/components/macros/option-utils.ts +14 -0
- package/src/expression.ts +93 -0
- package/src/index.tsx +120 -0
- package/src/markup/ast.ts +284 -0
- package/src/markup/markdown.ts +21 -0
- package/src/markup/render.tsx +537 -0
- package/src/markup/tokenizer.ts +581 -0
- package/src/parser.ts +72 -0
- package/src/registry.ts +21 -0
- package/src/saves/idb.ts +165 -0
- package/src/saves/save-manager.ts +317 -0
- package/src/saves/types.ts +40 -0
- package/src/settings.ts +96 -0
- package/src/store.ts +317 -0
- package/src/story-api.ts +129 -0
- package/src/story-init.ts +67 -0
- package/src/story-variables.ts +166 -0
- package/src/styles.css +780 -0
- package/src/utils/parse-delay.ts +14 -0
- package/src/widgets/widget-registry.ts +15 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { useLayoutEffect } from 'preact/hooks';
|
|
2
|
+
import { useContext } from 'preact/hooks';
|
|
3
|
+
import { useStoryStore } from '../../store';
|
|
4
|
+
import { evaluate } from '../../expression';
|
|
5
|
+
import { LocalsContext } from '../../markup/render';
|
|
6
|
+
|
|
7
|
+
interface ComputedProps {
|
|
8
|
+
rawArgs: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function parseComputedArgs(rawArgs: string): { target: string; expr: string } {
|
|
12
|
+
const trimmed = rawArgs.trim();
|
|
13
|
+
|
|
14
|
+
// Find the first '=' that isn't part of '==' or '!='
|
|
15
|
+
let depth = 0;
|
|
16
|
+
for (let i = 0; i < trimmed.length; i++) {
|
|
17
|
+
const ch = trimmed[i];
|
|
18
|
+
if (ch === '(' || ch === '[' || ch === '{') depth++;
|
|
19
|
+
else if (ch === ')' || ch === ']' || ch === '}') depth--;
|
|
20
|
+
else if (ch === '=' && depth === 0) {
|
|
21
|
+
if (trimmed[i + 1] === '=') {
|
|
22
|
+
i++;
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
if (i > 0 && trimmed[i - 1] === '!') continue;
|
|
26
|
+
|
|
27
|
+
const target = trimmed.slice(0, i).trim();
|
|
28
|
+
const expr = trimmed.slice(i + 1).trim();
|
|
29
|
+
|
|
30
|
+
if (!target.match(/^[$_]\w+$/)) {
|
|
31
|
+
throw new Error(
|
|
32
|
+
`{computed}: target must be $name or _name, got "${target}"`,
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return { target, expr };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
throw new Error(
|
|
41
|
+
`{computed}: expected "target = expression", got "${rawArgs}"`,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function valuesEqual(a: unknown, b: unknown): boolean {
|
|
46
|
+
if (Object.is(a, b)) return true;
|
|
47
|
+
if (
|
|
48
|
+
typeof a === 'object' &&
|
|
49
|
+
a !== null &&
|
|
50
|
+
typeof b === 'object' &&
|
|
51
|
+
b !== null
|
|
52
|
+
) {
|
|
53
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
54
|
+
}
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function Computed({ rawArgs }: ComputedProps) {
|
|
59
|
+
// Subscribe to trigger re-renders when store changes
|
|
60
|
+
useStoryStore((s) => s.variables);
|
|
61
|
+
useStoryStore((s) => s.temporary);
|
|
62
|
+
const locals = useContext(LocalsContext);
|
|
63
|
+
|
|
64
|
+
const { target, expr } = parseComputedArgs(rawArgs);
|
|
65
|
+
const isTemp = target.startsWith('_');
|
|
66
|
+
const name = target.slice(1);
|
|
67
|
+
|
|
68
|
+
// Evaluate in useLayoutEffect so preceding {set} effects have already run
|
|
69
|
+
useLayoutEffect(() => {
|
|
70
|
+
const state = useStoryStore.getState();
|
|
71
|
+
|
|
72
|
+
// Merge locals from for-loops
|
|
73
|
+
const mergedVars = { ...state.variables };
|
|
74
|
+
const mergedTemps = { ...state.temporary };
|
|
75
|
+
for (const [key, val] of Object.entries(locals)) {
|
|
76
|
+
if (key.startsWith('$')) mergedVars[key.slice(1)] = val;
|
|
77
|
+
else if (key.startsWith('_')) mergedTemps[key.slice(1)] = val;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
let newValue: unknown;
|
|
81
|
+
try {
|
|
82
|
+
newValue = evaluate(expr, mergedVars, mergedTemps);
|
|
83
|
+
} catch (err) {
|
|
84
|
+
console.error(`spindle: Error in {computed ${rawArgs}}:`, err);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const current = isTemp ? state.temporary[name] : state.variables[name];
|
|
89
|
+
|
|
90
|
+
if (!valuesEqual(current, newValue)) {
|
|
91
|
+
if (isTemp) {
|
|
92
|
+
state.setTemporary(name, newValue);
|
|
93
|
+
} else {
|
|
94
|
+
state.setVariable(name, newValue);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { useStoryStore } from '../../store';
|
|
2
|
+
import { extractOptions } from './option-utils';
|
|
3
|
+
import type { ASTNode } from '../../markup/ast';
|
|
4
|
+
|
|
5
|
+
interface CycleProps {
|
|
6
|
+
rawArgs: string;
|
|
7
|
+
children: ASTNode[];
|
|
8
|
+
className?: string;
|
|
9
|
+
id?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function Cycle({ rawArgs, children, className, id }: CycleProps) {
|
|
13
|
+
const varName = rawArgs.trim().replace(/["']/g, '');
|
|
14
|
+
const name = varName.startsWith('$') ? varName.slice(1) : varName;
|
|
15
|
+
|
|
16
|
+
const value = useStoryStore((s) => s.variables[name]);
|
|
17
|
+
const setVariable = useStoryStore((s) => s.setVariable);
|
|
18
|
+
|
|
19
|
+
const options = extractOptions(children);
|
|
20
|
+
|
|
21
|
+
const handleClick = () => {
|
|
22
|
+
if (options.length === 0) return;
|
|
23
|
+
const currentIndex = options.indexOf(String(value));
|
|
24
|
+
const nextIndex = (currentIndex + 1) % options.length;
|
|
25
|
+
setVariable(name, options[nextIndex]);
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const cls = className ? `macro-cycle ${className}` : 'macro-cycle';
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<button
|
|
32
|
+
id={id}
|
|
33
|
+
class={cls}
|
|
34
|
+
onClick={handleClick}
|
|
35
|
+
>
|
|
36
|
+
{value == null ? (options[0] || '') : String(value)}
|
|
37
|
+
</button>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { useLayoutEffect } from 'preact/hooks';
|
|
2
|
+
import { useStoryStore } from '../../store';
|
|
3
|
+
import { execute } from '../../expression';
|
|
4
|
+
import type { ASTNode } from '../../markup/ast';
|
|
5
|
+
|
|
6
|
+
interface DoProps {
|
|
7
|
+
children: ASTNode[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Concatenate text children into a single JS string for execution.
|
|
12
|
+
*/
|
|
13
|
+
function collectText(nodes: ASTNode[]): string {
|
|
14
|
+
return nodes.map((n) => (n.type === 'text' ? n.value : '')).join('');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function Do({ children }: DoProps) {
|
|
18
|
+
const code = collectText(children);
|
|
19
|
+
|
|
20
|
+
useLayoutEffect(() => {
|
|
21
|
+
const state = useStoryStore.getState();
|
|
22
|
+
const vars = { ...state.variables };
|
|
23
|
+
const temps = { ...state.temporary };
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
execute(code, vars, temps);
|
|
27
|
+
} catch (err) {
|
|
28
|
+
console.error(`spindle: Error in {do}:`, err);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Diff and apply
|
|
33
|
+
for (const key of Object.keys(vars)) {
|
|
34
|
+
if (vars[key] !== state.variables[key]) {
|
|
35
|
+
state.setVariable(key, vars[key]);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
for (const key of Object.keys(temps)) {
|
|
39
|
+
if (temps[key] !== state.temporary[key]) {
|
|
40
|
+
state.setTemporary(key, temps[key]);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}, []);
|
|
44
|
+
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { useStoryStore } from '../../store';
|
|
2
|
+
import { useContext } from 'preact/hooks';
|
|
3
|
+
import { evaluate } from '../../expression';
|
|
4
|
+
import { LocalsContext } from '../../markup/render';
|
|
5
|
+
import { renderNodes } from '../../markup/render';
|
|
6
|
+
import type { ASTNode } from '../../markup/ast';
|
|
7
|
+
|
|
8
|
+
interface ForProps {
|
|
9
|
+
rawArgs: string;
|
|
10
|
+
children: ASTNode[];
|
|
11
|
+
className?: string;
|
|
12
|
+
id?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Parse for-loop args: "$item, $i of $list" or "$item of $list"
|
|
17
|
+
*/
|
|
18
|
+
function parseForArgs(rawArgs: string): {
|
|
19
|
+
itemVar: string;
|
|
20
|
+
indexVar: string | null;
|
|
21
|
+
listExpr: string;
|
|
22
|
+
} {
|
|
23
|
+
const ofIdx = rawArgs.indexOf(' of ');
|
|
24
|
+
if (ofIdx === -1) {
|
|
25
|
+
throw new Error(`{for} requires "of" keyword: {for ${rawArgs}}`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const varsPart = rawArgs.slice(0, ofIdx).trim();
|
|
29
|
+
const listExpr = rawArgs.slice(ofIdx + 4).trim();
|
|
30
|
+
|
|
31
|
+
const vars = varsPart.split(',').map((v) => v.trim());
|
|
32
|
+
const itemVar = vars[0];
|
|
33
|
+
const indexVar = vars.length > 1 ? vars[1] : null;
|
|
34
|
+
|
|
35
|
+
return { itemVar, indexVar, listExpr };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function For({ rawArgs, children, className, id }: ForProps) {
|
|
39
|
+
const variables = useStoryStore((s) => s.variables);
|
|
40
|
+
const temporary = useStoryStore((s) => s.temporary);
|
|
41
|
+
const parentLocals = useContext(LocalsContext);
|
|
42
|
+
|
|
43
|
+
// Merge parent locals for expression evaluation
|
|
44
|
+
const mergedVars = { ...variables };
|
|
45
|
+
const mergedTemps = { ...temporary };
|
|
46
|
+
for (const [key, val] of Object.entries(parentLocals)) {
|
|
47
|
+
if (key.startsWith('$')) mergedVars[key.slice(1)] = val;
|
|
48
|
+
else if (key.startsWith('_')) mergedTemps[key.slice(1)] = val;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let parsed: ReturnType<typeof parseForArgs>;
|
|
52
|
+
try {
|
|
53
|
+
parsed = parseForArgs(rawArgs);
|
|
54
|
+
} catch (err) {
|
|
55
|
+
return (
|
|
56
|
+
<span
|
|
57
|
+
class="error"
|
|
58
|
+
title={String(err)}
|
|
59
|
+
>
|
|
60
|
+
{`{for error: ${(err as Error).message}}`}
|
|
61
|
+
</span>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const { itemVar, indexVar, listExpr } = parsed;
|
|
66
|
+
|
|
67
|
+
let list: unknown[];
|
|
68
|
+
try {
|
|
69
|
+
const result = evaluate(listExpr, mergedVars, mergedTemps);
|
|
70
|
+
if (!Array.isArray(result)) {
|
|
71
|
+
return (
|
|
72
|
+
<span class="error">
|
|
73
|
+
{`{for error: expression did not evaluate to an array}`}
|
|
74
|
+
</span>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
list = result;
|
|
78
|
+
} catch (err) {
|
|
79
|
+
return (
|
|
80
|
+
<span
|
|
81
|
+
class="error"
|
|
82
|
+
title={String(err)}
|
|
83
|
+
>
|
|
84
|
+
{`{for error: ${(err as Error).message}}`}
|
|
85
|
+
</span>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const content = list.map((item, i) => {
|
|
90
|
+
const locals = { ...parentLocals, [itemVar]: item };
|
|
91
|
+
if (indexVar) locals[indexVar] = i;
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<LocalsContext.Provider
|
|
95
|
+
key={i}
|
|
96
|
+
value={locals}
|
|
97
|
+
>
|
|
98
|
+
{renderNodes(children)}
|
|
99
|
+
</LocalsContext.Provider>
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
if (className || id)
|
|
104
|
+
return (
|
|
105
|
+
<span
|
|
106
|
+
id={id}
|
|
107
|
+
class={className}
|
|
108
|
+
>
|
|
109
|
+
{content}
|
|
110
|
+
</span>
|
|
111
|
+
);
|
|
112
|
+
return <>{content}</>;
|
|
113
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { useStoryStore } from '../../store';
|
|
2
|
+
|
|
3
|
+
interface ForwardProps {
|
|
4
|
+
className?: string;
|
|
5
|
+
id?: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function Forward({ className, id }: ForwardProps) {
|
|
9
|
+
const goForward = useStoryStore((s) => s.goForward);
|
|
10
|
+
const canGoForward = useStoryStore(
|
|
11
|
+
(s) => s.historyIndex < s.history.length - 1,
|
|
12
|
+
);
|
|
13
|
+
const cls = className ? `menubar-button ${className}` : 'menubar-button';
|
|
14
|
+
|
|
15
|
+
return (
|
|
16
|
+
<button
|
|
17
|
+
id={id}
|
|
18
|
+
class={cls}
|
|
19
|
+
onClick={goForward}
|
|
20
|
+
disabled={!canGoForward}
|
|
21
|
+
>
|
|
22
|
+
Forward →
|
|
23
|
+
</button>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { useLayoutEffect } from 'preact/hooks';
|
|
2
|
+
import { useStoryStore } from '../../store';
|
|
3
|
+
import { evaluate } from '../../expression';
|
|
4
|
+
|
|
5
|
+
interface GotoProps {
|
|
6
|
+
rawArgs: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function Goto({ rawArgs }: GotoProps) {
|
|
10
|
+
useLayoutEffect(() => {
|
|
11
|
+
const state = useStoryStore.getState();
|
|
12
|
+
let passageName: string;
|
|
13
|
+
try {
|
|
14
|
+
const result = evaluate(rawArgs, state.variables, state.temporary);
|
|
15
|
+
passageName = String(result);
|
|
16
|
+
} catch {
|
|
17
|
+
passageName = rawArgs.replace(/^["']|["']$/g, '');
|
|
18
|
+
}
|
|
19
|
+
state.navigate(passageName);
|
|
20
|
+
}, []);
|
|
21
|
+
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { useStoryStore } from '../../store';
|
|
2
|
+
import { useContext } from 'preact/hooks';
|
|
3
|
+
import { evaluate } from '../../expression';
|
|
4
|
+
import { LocalsContext } from '../../markup/render';
|
|
5
|
+
import { renderNodes } from '../../markup/render';
|
|
6
|
+
import type { Branch } from '../../markup/ast';
|
|
7
|
+
|
|
8
|
+
interface IfProps {
|
|
9
|
+
branches: Branch[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function If({ branches }: IfProps) {
|
|
13
|
+
const variables = useStoryStore((s) => s.variables);
|
|
14
|
+
const temporary = useStoryStore((s) => s.temporary);
|
|
15
|
+
const locals = useContext(LocalsContext);
|
|
16
|
+
|
|
17
|
+
// Merge locals for expression evaluation
|
|
18
|
+
const mergedVars = { ...variables };
|
|
19
|
+
const mergedTemps = { ...temporary };
|
|
20
|
+
for (const [key, val] of Object.entries(locals)) {
|
|
21
|
+
if (key.startsWith('$')) mergedVars[key.slice(1)] = val;
|
|
22
|
+
else if (key.startsWith('_')) mergedTemps[key.slice(1)] = val;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function renderBranch(branch: Branch) {
|
|
26
|
+
const children = renderNodes(branch.children);
|
|
27
|
+
if (branch.className || branch.id)
|
|
28
|
+
return (
|
|
29
|
+
<span
|
|
30
|
+
id={branch.id}
|
|
31
|
+
class={branch.className}
|
|
32
|
+
>
|
|
33
|
+
{children}
|
|
34
|
+
</span>
|
|
35
|
+
);
|
|
36
|
+
return <>{children}</>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
for (const branch of branches) {
|
|
40
|
+
// {else} has empty rawArgs — always truthy
|
|
41
|
+
if (branch.rawArgs === '') {
|
|
42
|
+
return renderBranch(branch);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
const result = evaluate(branch.rawArgs, mergedVars, mergedTemps);
|
|
47
|
+
if (result) {
|
|
48
|
+
return renderBranch(branch);
|
|
49
|
+
}
|
|
50
|
+
} catch (err) {
|
|
51
|
+
return (
|
|
52
|
+
<span
|
|
53
|
+
class="error"
|
|
54
|
+
title={String(err)}
|
|
55
|
+
>
|
|
56
|
+
{`{if error: ${(err as Error).message}}`}
|
|
57
|
+
</span>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { useStoryStore } from '../../store';
|
|
2
|
+
import { evaluate } from '../../expression';
|
|
3
|
+
import { tokenize } from '../../markup/tokenizer';
|
|
4
|
+
import { buildAST } from '../../markup/ast';
|
|
5
|
+
import { renderNodes } from '../../markup/render';
|
|
6
|
+
|
|
7
|
+
interface IncludeProps {
|
|
8
|
+
rawArgs: string;
|
|
9
|
+
className?: string;
|
|
10
|
+
id?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function Include({ rawArgs, className, id }: IncludeProps) {
|
|
14
|
+
const storyData = useStoryStore((s) => s.storyData);
|
|
15
|
+
const variables = useStoryStore((s) => s.variables);
|
|
16
|
+
const temporary = useStoryStore((s) => s.temporary);
|
|
17
|
+
|
|
18
|
+
if (!storyData) return null;
|
|
19
|
+
|
|
20
|
+
let passageName: string;
|
|
21
|
+
try {
|
|
22
|
+
const result = evaluate(rawArgs, variables, temporary);
|
|
23
|
+
passageName = String(result);
|
|
24
|
+
} catch {
|
|
25
|
+
passageName = rawArgs.replace(/^["']|["']$/g, '');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const passage = storyData.passages.get(passageName);
|
|
29
|
+
if (passage) {
|
|
30
|
+
useStoryStore.getState().trackRender(passageName);
|
|
31
|
+
}
|
|
32
|
+
if (!passage) {
|
|
33
|
+
return (
|
|
34
|
+
<span class="error">{`{include: passage "${passageName}" not found}`}</span>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const tokens = tokenize(passage.content);
|
|
39
|
+
const ast = buildAST(tokens);
|
|
40
|
+
const content = renderNodes(ast);
|
|
41
|
+
|
|
42
|
+
if (className || id)
|
|
43
|
+
return (
|
|
44
|
+
<span
|
|
45
|
+
id={id}
|
|
46
|
+
class={className}
|
|
47
|
+
>
|
|
48
|
+
{content}
|
|
49
|
+
</span>
|
|
50
|
+
);
|
|
51
|
+
return <>{content}</>;
|
|
52
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { useStoryStore } from '../../store';
|
|
2
|
+
import { extractOptions } from './option-utils';
|
|
3
|
+
import type { ASTNode } from '../../markup/ast';
|
|
4
|
+
|
|
5
|
+
interface ListboxProps {
|
|
6
|
+
rawArgs: string;
|
|
7
|
+
children: ASTNode[];
|
|
8
|
+
className?: string;
|
|
9
|
+
id?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function Listbox({ rawArgs, children, className, id }: ListboxProps) {
|
|
13
|
+
const varName = rawArgs.trim().replace(/["']/g, '');
|
|
14
|
+
const name = varName.startsWith('$') ? varName.slice(1) : varName;
|
|
15
|
+
|
|
16
|
+
const value = useStoryStore((s) => s.variables[name]);
|
|
17
|
+
const setVariable = useStoryStore((s) => s.setVariable);
|
|
18
|
+
|
|
19
|
+
const options = extractOptions(children);
|
|
20
|
+
|
|
21
|
+
const cls = className ? `macro-listbox ${className}` : 'macro-listbox';
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<select
|
|
25
|
+
id={id}
|
|
26
|
+
class={cls}
|
|
27
|
+
value={value == null ? '' : String(value)}
|
|
28
|
+
onChange={(e) =>
|
|
29
|
+
setVariable(name, (e.target as HTMLSelectElement).value)
|
|
30
|
+
}
|
|
31
|
+
>
|
|
32
|
+
{options.map((opt) => (
|
|
33
|
+
<option
|
|
34
|
+
key={opt}
|
|
35
|
+
value={opt}
|
|
36
|
+
>
|
|
37
|
+
{opt}
|
|
38
|
+
</option>
|
|
39
|
+
))}
|
|
40
|
+
</select>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { useStoryStore } from '../../store';
|
|
2
|
+
import { execute } from '../../expression';
|
|
3
|
+
import type { ASTNode } from '../../markup/ast';
|
|
4
|
+
|
|
5
|
+
interface MacroLinkProps {
|
|
6
|
+
rawArgs: string;
|
|
7
|
+
children: ASTNode[];
|
|
8
|
+
className?: string;
|
|
9
|
+
id?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function parseArgs(rawArgs: string): {
|
|
13
|
+
display: string;
|
|
14
|
+
passage: string | null;
|
|
15
|
+
} {
|
|
16
|
+
// {link "text" "Passage"} or {link "text"}
|
|
17
|
+
const parts: string[] = [];
|
|
18
|
+
const re = /["']([^"']+)["']/g;
|
|
19
|
+
let m;
|
|
20
|
+
while ((m = re.exec(rawArgs)) !== null) {
|
|
21
|
+
parts.push(m[1]);
|
|
22
|
+
}
|
|
23
|
+
if (parts.length >= 2) {
|
|
24
|
+
return { display: parts[0], passage: parts[1] };
|
|
25
|
+
}
|
|
26
|
+
if (parts.length === 1) {
|
|
27
|
+
return { display: parts[0], passage: null };
|
|
28
|
+
}
|
|
29
|
+
// Fallback: treat entire rawArgs as display text
|
|
30
|
+
return { display: rawArgs.trim(), passage: null };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Collect text from AST nodes for imperative execution (like Do.tsx).
|
|
35
|
+
*/
|
|
36
|
+
function collectText(nodes: ASTNode[]): string {
|
|
37
|
+
return nodes.map((n) => (n.type === 'text' ? n.value : '')).join('');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Execute the children imperatively: walk AST for {set} and {do} macros.
|
|
42
|
+
*/
|
|
43
|
+
function executeChildren(children: ASTNode[]) {
|
|
44
|
+
const state = useStoryStore.getState();
|
|
45
|
+
const vars = structuredClone(state.variables);
|
|
46
|
+
const temps = structuredClone(state.temporary);
|
|
47
|
+
|
|
48
|
+
for (const node of children) {
|
|
49
|
+
if (node.type !== 'macro') continue;
|
|
50
|
+
if (node.name === 'set') {
|
|
51
|
+
try {
|
|
52
|
+
execute(node.rawArgs, vars, temps);
|
|
53
|
+
} catch (err) {
|
|
54
|
+
console.error(`spindle: Error in {link} child {set}:`, err);
|
|
55
|
+
}
|
|
56
|
+
} else if (node.name === 'do') {
|
|
57
|
+
const code = collectText(node.children);
|
|
58
|
+
try {
|
|
59
|
+
execute(code, vars, temps);
|
|
60
|
+
} catch (err) {
|
|
61
|
+
console.error(`spindle: Error in {link} child {do}:`, err);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Apply changes
|
|
67
|
+
for (const key of Object.keys(vars)) {
|
|
68
|
+
if (vars[key] !== state.variables[key]) {
|
|
69
|
+
state.setVariable(key, vars[key]);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
for (const key of Object.keys(temps)) {
|
|
73
|
+
if (temps[key] !== state.temporary[key]) {
|
|
74
|
+
state.setTemporary(key, temps[key]);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function MacroLink({
|
|
80
|
+
rawArgs,
|
|
81
|
+
children,
|
|
82
|
+
className,
|
|
83
|
+
id,
|
|
84
|
+
}: MacroLinkProps) {
|
|
85
|
+
const { display, passage } = parseArgs(rawArgs);
|
|
86
|
+
|
|
87
|
+
const handleClick = (e: Event) => {
|
|
88
|
+
e.preventDefault();
|
|
89
|
+
executeChildren(children);
|
|
90
|
+
if (passage) {
|
|
91
|
+
useStoryStore.getState().navigate(passage);
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const cls = className ? `macro-link ${className}` : 'macro-link';
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<a
|
|
99
|
+
id={id}
|
|
100
|
+
class={cls}
|
|
101
|
+
href="#"
|
|
102
|
+
onClick={handleClick}
|
|
103
|
+
>
|
|
104
|
+
{display}
|
|
105
|
+
</a>
|
|
106
|
+
);
|
|
107
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { useStoryStore } from '../../store';
|
|
2
|
+
|
|
3
|
+
interface NumberboxProps {
|
|
4
|
+
rawArgs: string;
|
|
5
|
+
className?: string;
|
|
6
|
+
id?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function parseArgs(rawArgs: string): { varName: string; placeholder: string } {
|
|
10
|
+
const match = rawArgs.match(
|
|
11
|
+
/^\s*(["']?\$\w+["']?)\s*(?:["'](.*)["'])?\s*$/,
|
|
12
|
+
);
|
|
13
|
+
if (!match) {
|
|
14
|
+
return { varName: rawArgs.trim(), placeholder: '' };
|
|
15
|
+
}
|
|
16
|
+
const varName = match[1].replace(/["']/g, '');
|
|
17
|
+
const placeholder = match[2] || '';
|
|
18
|
+
return { varName, placeholder };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function Numberbox({ rawArgs, className, id }: NumberboxProps) {
|
|
22
|
+
const { varName, placeholder } = parseArgs(rawArgs);
|
|
23
|
+
const name = varName.startsWith('$') ? varName.slice(1) : varName;
|
|
24
|
+
|
|
25
|
+
const value = useStoryStore((s) => s.variables[name]);
|
|
26
|
+
const setVariable = useStoryStore((s) => s.setVariable);
|
|
27
|
+
|
|
28
|
+
const cls = className ? `macro-numberbox ${className}` : 'macro-numberbox';
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<input
|
|
32
|
+
type="number"
|
|
33
|
+
id={id}
|
|
34
|
+
class={cls}
|
|
35
|
+
value={value == null ? '' : String(value)}
|
|
36
|
+
placeholder={placeholder}
|
|
37
|
+
onInput={(e) => {
|
|
38
|
+
const val = (e.target as HTMLInputElement).value;
|
|
39
|
+
setVariable(name, val === '' ? 0 : Number(val));
|
|
40
|
+
}}
|
|
41
|
+
/>
|
|
42
|
+
);
|
|
43
|
+
}
|