@rohal12/spindle 0.1.0 → 0.3.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 +5 -2
- package/src/action-registry.ts +83 -0
- package/src/automation/index.ts +11 -0
- package/src/automation/load-yaml.ts +20 -0
- package/src/automation/runner.ts +139 -0
- package/src/automation/types.ts +38 -0
- package/src/class-registry.ts +189 -0
- package/src/components/PassageLink.tsx +10 -0
- package/src/components/StoryInterface.tsx +2 -1
- package/src/components/macros/Back.tsx +10 -0
- package/src/components/macros/Button.tsx +13 -2
- package/src/components/macros/Checkbox.tsx +11 -0
- package/src/components/macros/Cycle.tsx +19 -1
- package/src/components/macros/Do.tsx +3 -2
- package/src/components/macros/Forward.tsx +10 -0
- package/src/components/macros/Listbox.tsx +15 -3
- package/src/components/macros/MacroLink.tsx +19 -8
- package/src/components/macros/Meter.tsx +130 -0
- package/src/components/macros/Numberbox.tsx +12 -3
- package/src/components/macros/QuickLoad.tsx +10 -0
- package/src/components/macros/QuickSave.tsx +9 -0
- package/src/components/macros/Radiobutton.tsx +11 -0
- package/src/components/macros/Restart.tsx +9 -0
- package/src/components/macros/Set.tsx +3 -2
- package/src/components/macros/Textarea.tsx +12 -3
- package/src/components/macros/Textbox.tsx +12 -3
- package/src/components/macros/Type.tsx +2 -9
- package/src/hooks/use-action.ts +49 -0
- package/src/index.tsx +11 -5
- package/src/markup/ast.ts +3 -6
- package/src/markup/markdown.ts +1 -4
- package/src/markup/render.tsx +13 -6
- package/src/markup/tokenizer.ts +66 -8
- package/src/saves/save-manager.ts +24 -4
- package/src/store.ts +17 -13
- package/src/story-api.ts +97 -0
- package/src/story-init.ts +3 -2
- package/src/story-variables.ts +3 -1
- package/src/styles.css +31 -1
- package/src/utils/extract-text.ts +8 -0
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useStoryStore } from '../../store';
|
|
2
|
+
import { useAction } from '../../hooks/use-action';
|
|
2
3
|
|
|
3
4
|
interface ForwardProps {
|
|
4
5
|
className?: string;
|
|
@@ -12,6 +13,15 @@ export function Forward({ className, id }: ForwardProps) {
|
|
|
12
13
|
);
|
|
13
14
|
const cls = className ? `menubar-button ${className}` : 'menubar-button';
|
|
14
15
|
|
|
16
|
+
useAction({
|
|
17
|
+
type: 'forward',
|
|
18
|
+
key: 'forward',
|
|
19
|
+
authorId: id,
|
|
20
|
+
label: 'Forward',
|
|
21
|
+
disabled: !canGoForward,
|
|
22
|
+
perform: () => goForward(),
|
|
23
|
+
});
|
|
24
|
+
|
|
15
25
|
return (
|
|
16
26
|
<button
|
|
17
27
|
id={id}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useStoryStore } from '../../store';
|
|
2
2
|
import { extractOptions } from './option-utils';
|
|
3
|
+
import { useAction } from '../../hooks/use-action';
|
|
3
4
|
import type { ASTNode } from '../../markup/ast';
|
|
4
5
|
|
|
5
6
|
interface ListboxProps {
|
|
@@ -18,6 +19,19 @@ export function Listbox({ rawArgs, children, className, id }: ListboxProps) {
|
|
|
18
19
|
|
|
19
20
|
const options = extractOptions(children);
|
|
20
21
|
|
|
22
|
+
useAction({
|
|
23
|
+
type: 'listbox',
|
|
24
|
+
key: `$${name}`,
|
|
25
|
+
authorId: id,
|
|
26
|
+
label: name,
|
|
27
|
+
variable: name,
|
|
28
|
+
options,
|
|
29
|
+
value,
|
|
30
|
+
perform: (v) => {
|
|
31
|
+
if (v !== undefined) setVariable(name, String(v));
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
|
|
21
35
|
const cls = className ? `macro-listbox ${className}` : 'macro-listbox';
|
|
22
36
|
|
|
23
37
|
return (
|
|
@@ -25,9 +39,7 @@ export function Listbox({ rawArgs, children, className, id }: ListboxProps) {
|
|
|
25
39
|
id={id}
|
|
26
40
|
class={cls}
|
|
27
41
|
value={value == null ? '' : String(value)}
|
|
28
|
-
onChange={(e) =>
|
|
29
|
-
setVariable(name, (e.target as HTMLSelectElement).value)
|
|
30
|
-
}
|
|
42
|
+
onChange={(e) => setVariable(name, (e.target as HTMLSelectElement).value)}
|
|
31
43
|
>
|
|
32
44
|
{options.map((opt) => (
|
|
33
45
|
<option
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useStoryStore } from '../../store';
|
|
2
2
|
import { execute } from '../../expression';
|
|
3
3
|
import type { ASTNode } from '../../markup/ast';
|
|
4
|
+
import { deepClone } from '../../class-registry';
|
|
4
5
|
|
|
5
6
|
interface MacroLinkProps {
|
|
6
7
|
rawArgs: string;
|
|
@@ -30,20 +31,16 @@ function parseArgs(rawArgs: string): {
|
|
|
30
31
|
return { display: rawArgs.trim(), passage: null };
|
|
31
32
|
}
|
|
32
33
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
*/
|
|
36
|
-
function collectText(nodes: ASTNode[]): string {
|
|
37
|
-
return nodes.map((n) => (n.type === 'text' ? n.value : '')).join('');
|
|
38
|
-
}
|
|
34
|
+
import { collectText } from '../../utils/extract-text';
|
|
35
|
+
import { useAction } from '../../hooks/use-action';
|
|
39
36
|
|
|
40
37
|
/**
|
|
41
38
|
* Execute the children imperatively: walk AST for {set} and {do} macros.
|
|
42
39
|
*/
|
|
43
40
|
function executeChildren(children: ASTNode[]) {
|
|
44
41
|
const state = useStoryStore.getState();
|
|
45
|
-
const vars =
|
|
46
|
-
const temps =
|
|
42
|
+
const vars = deepClone(state.variables);
|
|
43
|
+
const temps = deepClone(state.temporary);
|
|
47
44
|
|
|
48
45
|
for (const node of children) {
|
|
49
46
|
if (node.type !== 'macro') continue;
|
|
@@ -92,6 +89,20 @@ export function MacroLink({
|
|
|
92
89
|
}
|
|
93
90
|
};
|
|
94
91
|
|
|
92
|
+
useAction({
|
|
93
|
+
type: 'link',
|
|
94
|
+
key: passage || display,
|
|
95
|
+
authorId: id,
|
|
96
|
+
label: display,
|
|
97
|
+
target: passage ?? undefined,
|
|
98
|
+
perform: () => {
|
|
99
|
+
executeChildren(children);
|
|
100
|
+
if (passage) {
|
|
101
|
+
useStoryStore.getState().navigate(passage);
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
|
|
95
106
|
const cls = className ? `macro-link ${className}` : 'macro-link';
|
|
96
107
|
|
|
97
108
|
return (
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { useStoryStore } from '../../store';
|
|
2
|
+
import { useContext } from 'preact/hooks';
|
|
3
|
+
import { evaluate } from '../../expression';
|
|
4
|
+
import { LocalsContext } from '../../markup/render';
|
|
5
|
+
|
|
6
|
+
interface MeterProps {
|
|
7
|
+
rawArgs: string;
|
|
8
|
+
className?: string;
|
|
9
|
+
id?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Parse rawArgs into currentExpr, maxExpr, and optional labelMode.
|
|
14
|
+
* Supports: {meter $current $max}, {meter $current $max "%"}, etc.
|
|
15
|
+
*/
|
|
16
|
+
function parseArgs(rawArgs: string): {
|
|
17
|
+
currentExpr: string;
|
|
18
|
+
maxExpr: string;
|
|
19
|
+
labelMode: string;
|
|
20
|
+
} {
|
|
21
|
+
const trimmed = rawArgs.trim();
|
|
22
|
+
|
|
23
|
+
// Extract quoted label mode from the end if present
|
|
24
|
+
let labelMode = '';
|
|
25
|
+
let rest = trimmed;
|
|
26
|
+
const quoteMatch = rest.match(/\s+(?:"([^"]*)"|'([^']*)')$/);
|
|
27
|
+
if (quoteMatch) {
|
|
28
|
+
labelMode = quoteMatch[1] ?? quoteMatch[2];
|
|
29
|
+
rest = rest.slice(0, quoteMatch.index!).trim();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Split remaining into two expressions.
|
|
33
|
+
// Expressions can contain dots, brackets, parens — we need to find the split point.
|
|
34
|
+
// Strategy: walk tokens, splitting on whitespace that isn't inside parens/brackets.
|
|
35
|
+
const exprs = splitExpressions(rest);
|
|
36
|
+
if (exprs.length < 2) {
|
|
37
|
+
throw new Error(
|
|
38
|
+
'meter requires two arguments: {meter currentExpr maxExpr}',
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
currentExpr: exprs[0],
|
|
44
|
+
maxExpr: exprs.slice(1).join(' '),
|
|
45
|
+
labelMode,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function splitExpressions(input: string): string[] {
|
|
50
|
+
const result: string[] = [];
|
|
51
|
+
let current = '';
|
|
52
|
+
let depth = 0;
|
|
53
|
+
|
|
54
|
+
for (let i = 0; i < input.length; i++) {
|
|
55
|
+
const ch = input[i];
|
|
56
|
+
if (ch === '(' || ch === '[') {
|
|
57
|
+
depth++;
|
|
58
|
+
current += ch;
|
|
59
|
+
} else if (ch === ')' || ch === ']') {
|
|
60
|
+
depth--;
|
|
61
|
+
current += ch;
|
|
62
|
+
} else if (/\s/.test(ch) && depth === 0 && current.length > 0) {
|
|
63
|
+
result.push(current);
|
|
64
|
+
current = '';
|
|
65
|
+
// Skip additional whitespace
|
|
66
|
+
while (i + 1 < input.length && /\s/.test(input[i + 1])) i++;
|
|
67
|
+
} else {
|
|
68
|
+
current += ch;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (current.length > 0) result.push(current);
|
|
72
|
+
return result;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function formatLabel(
|
|
76
|
+
current: number,
|
|
77
|
+
max: number,
|
|
78
|
+
labelMode: string,
|
|
79
|
+
): string | null {
|
|
80
|
+
if (labelMode === 'none') return null;
|
|
81
|
+
if (labelMode === '%') return `${Math.round((current / max) * 100)}%`;
|
|
82
|
+
if (labelMode) return `${current} ${labelMode} / ${max} ${labelMode}`;
|
|
83
|
+
return `${current} / ${max}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function Meter({ rawArgs, className, id }: MeterProps) {
|
|
87
|
+
const variables = useStoryStore((s) => s.variables);
|
|
88
|
+
const temporary = useStoryStore((s) => s.temporary);
|
|
89
|
+
const locals = useContext(LocalsContext);
|
|
90
|
+
|
|
91
|
+
// Merge locals into variables for expression evaluation
|
|
92
|
+
const mergedVars = { ...variables };
|
|
93
|
+
const mergedTemps = { ...temporary };
|
|
94
|
+
for (const [key, val] of Object.entries(locals)) {
|
|
95
|
+
if (key.startsWith('$')) mergedVars[key.slice(1)] = val;
|
|
96
|
+
else if (key.startsWith('_')) mergedTemps[key.slice(1)] = val;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
const { currentExpr, maxExpr, labelMode } = parseArgs(rawArgs);
|
|
101
|
+
const current = Number(evaluate(currentExpr, mergedVars, mergedTemps));
|
|
102
|
+
const max = Number(evaluate(maxExpr, mergedVars, mergedTemps));
|
|
103
|
+
const pct = Math.max(0, Math.min(100, (current / max) * 100));
|
|
104
|
+
const label = formatLabel(current, max, labelMode);
|
|
105
|
+
|
|
106
|
+
const classes = ['macro-meter', className].filter(Boolean).join(' ');
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
<div
|
|
110
|
+
class={classes}
|
|
111
|
+
id={id}
|
|
112
|
+
>
|
|
113
|
+
<div
|
|
114
|
+
class="macro-meter-fill"
|
|
115
|
+
style={`width: ${pct}%`}
|
|
116
|
+
/>
|
|
117
|
+
{label != null && <span class="macro-meter-label">{label}</span>}
|
|
118
|
+
</div>
|
|
119
|
+
);
|
|
120
|
+
} catch (err) {
|
|
121
|
+
return (
|
|
122
|
+
<span
|
|
123
|
+
class="error"
|
|
124
|
+
title={String(err)}
|
|
125
|
+
>
|
|
126
|
+
{`{meter error: ${(err as Error).message}}`}
|
|
127
|
+
</span>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useStoryStore } from '../../store';
|
|
2
|
+
import { useAction } from '../../hooks/use-action';
|
|
2
3
|
|
|
3
4
|
interface NumberboxProps {
|
|
4
5
|
rawArgs: string;
|
|
@@ -7,9 +8,7 @@ interface NumberboxProps {
|
|
|
7
8
|
}
|
|
8
9
|
|
|
9
10
|
function parseArgs(rawArgs: string): { varName: string; placeholder: string } {
|
|
10
|
-
const match = rawArgs.match(
|
|
11
|
-
/^\s*(["']?\$\w+["']?)\s*(?:["'](.*)["'])?\s*$/,
|
|
12
|
-
);
|
|
11
|
+
const match = rawArgs.match(/^\s*(["']?\$\w+["']?)\s*(?:["'](.*)["'])?\s*$/);
|
|
13
12
|
if (!match) {
|
|
14
13
|
return { varName: rawArgs.trim(), placeholder: '' };
|
|
15
14
|
}
|
|
@@ -25,6 +24,16 @@ export function Numberbox({ rawArgs, className, id }: NumberboxProps) {
|
|
|
25
24
|
const value = useStoryStore((s) => s.variables[name]);
|
|
26
25
|
const setVariable = useStoryStore((s) => s.setVariable);
|
|
27
26
|
|
|
27
|
+
useAction({
|
|
28
|
+
type: 'numberbox',
|
|
29
|
+
key: `$${name}`,
|
|
30
|
+
authorId: id,
|
|
31
|
+
label: placeholder || name,
|
|
32
|
+
variable: name,
|
|
33
|
+
value,
|
|
34
|
+
perform: (v) => setVariable(name, v !== undefined ? Number(v) : 0),
|
|
35
|
+
});
|
|
36
|
+
|
|
28
37
|
const cls = className ? `macro-numberbox ${className}` : 'macro-numberbox';
|
|
29
38
|
|
|
30
39
|
return (
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useStoryStore } from '../../store';
|
|
2
|
+
import { useAction } from '../../hooks/use-action';
|
|
2
3
|
|
|
3
4
|
interface QuickLoadProps {
|
|
4
5
|
className?: string;
|
|
@@ -19,6 +20,15 @@ export function QuickLoad({ className, id }: QuickLoadProps) {
|
|
|
19
20
|
}
|
|
20
21
|
};
|
|
21
22
|
|
|
23
|
+
useAction({
|
|
24
|
+
type: 'load',
|
|
25
|
+
key: 'quickload',
|
|
26
|
+
authorId: id,
|
|
27
|
+
label: 'QuickLoad',
|
|
28
|
+
disabled,
|
|
29
|
+
perform: () => load(),
|
|
30
|
+
});
|
|
31
|
+
|
|
22
32
|
return (
|
|
23
33
|
<button
|
|
24
34
|
id={id}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useStoryStore } from '../../store';
|
|
2
|
+
import { useAction } from '../../hooks/use-action';
|
|
2
3
|
|
|
3
4
|
interface QuickSaveProps {
|
|
4
5
|
className?: string;
|
|
@@ -9,6 +10,14 @@ export function QuickSave({ className, id }: QuickSaveProps) {
|
|
|
9
10
|
const save = useStoryStore((s) => s.save);
|
|
10
11
|
const cls = className ? `menubar-button ${className}` : 'menubar-button';
|
|
11
12
|
|
|
13
|
+
useAction({
|
|
14
|
+
type: 'save',
|
|
15
|
+
key: 'quicksave',
|
|
16
|
+
authorId: id,
|
|
17
|
+
label: 'QuickSave',
|
|
18
|
+
perform: () => save(),
|
|
19
|
+
});
|
|
20
|
+
|
|
12
21
|
return (
|
|
13
22
|
<button
|
|
14
23
|
id={id}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useStoryStore } from '../../store';
|
|
2
|
+
import { useAction } from '../../hooks/use-action';
|
|
2
3
|
|
|
3
4
|
interface RadiobuttonProps {
|
|
4
5
|
rawArgs: string;
|
|
@@ -38,6 +39,16 @@ export function Radiobutton({ rawArgs, className, id }: RadiobuttonProps) {
|
|
|
38
39
|
const currentValue = useStoryStore((s) => s.variables[name]);
|
|
39
40
|
const setVariable = useStoryStore((s) => s.setVariable);
|
|
40
41
|
|
|
42
|
+
useAction({
|
|
43
|
+
type: 'radiobutton',
|
|
44
|
+
key: `$${name}:${radioValue}`,
|
|
45
|
+
authorId: id,
|
|
46
|
+
label: label || radioValue,
|
|
47
|
+
variable: name,
|
|
48
|
+
value: currentValue,
|
|
49
|
+
perform: () => setVariable(name, radioValue),
|
|
50
|
+
});
|
|
51
|
+
|
|
41
52
|
const cls = className
|
|
42
53
|
? `macro-radiobutton ${className}`
|
|
43
54
|
: 'macro-radiobutton';
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useStoryStore } from '../../store';
|
|
2
|
+
import { useAction } from '../../hooks/use-action';
|
|
2
3
|
|
|
3
4
|
interface RestartProps {
|
|
4
5
|
className?: string;
|
|
@@ -15,6 +16,14 @@ export function Restart({ className, id }: RestartProps) {
|
|
|
15
16
|
}
|
|
16
17
|
};
|
|
17
18
|
|
|
19
|
+
useAction({
|
|
20
|
+
type: 'restart',
|
|
21
|
+
key: 'restart',
|
|
22
|
+
authorId: id,
|
|
23
|
+
label: 'Restart',
|
|
24
|
+
perform: () => restart(),
|
|
25
|
+
});
|
|
26
|
+
|
|
18
27
|
return (
|
|
19
28
|
<button
|
|
20
29
|
id={id}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useLayoutEffect } from 'preact/hooks';
|
|
2
2
|
import { useStoryStore } from '../../store';
|
|
3
3
|
import { execute } from '../../expression';
|
|
4
|
+
import { deepClone } from '../../class-registry';
|
|
4
5
|
|
|
5
6
|
interface SetProps {
|
|
6
7
|
rawArgs: string;
|
|
@@ -9,8 +10,8 @@ interface SetProps {
|
|
|
9
10
|
export function Set({ rawArgs }: SetProps) {
|
|
10
11
|
useLayoutEffect(() => {
|
|
11
12
|
const state = useStoryStore.getState();
|
|
12
|
-
const vars =
|
|
13
|
-
const temps =
|
|
13
|
+
const vars = deepClone(state.variables);
|
|
14
|
+
const temps = deepClone(state.temporary);
|
|
14
15
|
|
|
15
16
|
try {
|
|
16
17
|
execute(rawArgs, vars, temps);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useStoryStore } from '../../store';
|
|
2
|
+
import { useAction } from '../../hooks/use-action';
|
|
2
3
|
|
|
3
4
|
interface TextareaProps {
|
|
4
5
|
rawArgs: string;
|
|
@@ -7,9 +8,7 @@ interface TextareaProps {
|
|
|
7
8
|
}
|
|
8
9
|
|
|
9
10
|
function parseArgs(rawArgs: string): { varName: string; placeholder: string } {
|
|
10
|
-
const match = rawArgs.match(
|
|
11
|
-
/^\s*(["']?\$\w+["']?)\s*(?:["'](.*)["'])?\s*$/,
|
|
12
|
-
);
|
|
11
|
+
const match = rawArgs.match(/^\s*(["']?\$\w+["']?)\s*(?:["'](.*)["'])?\s*$/);
|
|
13
12
|
if (!match) {
|
|
14
13
|
return { varName: rawArgs.trim(), placeholder: '' };
|
|
15
14
|
}
|
|
@@ -25,6 +24,16 @@ export function Textarea({ rawArgs, className, id }: TextareaProps) {
|
|
|
25
24
|
const value = useStoryStore((s) => s.variables[name]);
|
|
26
25
|
const setVariable = useStoryStore((s) => s.setVariable);
|
|
27
26
|
|
|
27
|
+
useAction({
|
|
28
|
+
type: 'textarea',
|
|
29
|
+
key: `$${name}`,
|
|
30
|
+
authorId: id,
|
|
31
|
+
label: placeholder || name,
|
|
32
|
+
variable: name,
|
|
33
|
+
value,
|
|
34
|
+
perform: (v) => setVariable(name, v !== undefined ? String(v) : ''),
|
|
35
|
+
});
|
|
36
|
+
|
|
28
37
|
const cls = className ? `macro-textarea ${className}` : 'macro-textarea';
|
|
29
38
|
|
|
30
39
|
return (
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useStoryStore } from '../../store';
|
|
2
|
+
import { useAction } from '../../hooks/use-action';
|
|
2
3
|
|
|
3
4
|
interface TextboxProps {
|
|
4
5
|
rawArgs: string;
|
|
@@ -7,9 +8,7 @@ interface TextboxProps {
|
|
|
7
8
|
}
|
|
8
9
|
|
|
9
10
|
function parseArgs(rawArgs: string): { varName: string; placeholder: string } {
|
|
10
|
-
const match = rawArgs.match(
|
|
11
|
-
/^\s*(["']?\$\w+["']?)\s*(?:["'](.*)["'])?\s*$/,
|
|
12
|
-
);
|
|
11
|
+
const match = rawArgs.match(/^\s*(["']?\$\w+["']?)\s*(?:["'](.*)["'])?\s*$/);
|
|
13
12
|
if (!match) {
|
|
14
13
|
return { varName: rawArgs.trim(), placeholder: '' };
|
|
15
14
|
}
|
|
@@ -25,6 +24,16 @@ export function Textbox({ rawArgs, className, id }: TextboxProps) {
|
|
|
25
24
|
const value = useStoryStore((s) => s.variables[name]);
|
|
26
25
|
const setVariable = useStoryStore((s) => s.setVariable);
|
|
27
26
|
|
|
27
|
+
useAction({
|
|
28
|
+
type: 'textbox',
|
|
29
|
+
key: `$${name}`,
|
|
30
|
+
authorId: id,
|
|
31
|
+
label: placeholder || name,
|
|
32
|
+
variable: name,
|
|
33
|
+
value,
|
|
34
|
+
perform: (v) => setVariable(name, v !== undefined ? String(v) : ''),
|
|
35
|
+
});
|
|
36
|
+
|
|
28
37
|
const cls = className ? `macro-textbox ${className}` : 'macro-textbox';
|
|
29
38
|
|
|
30
39
|
return (
|
|
@@ -44,11 +44,7 @@ export function Type({ rawArgs, children, className, id }: TypeProps) {
|
|
|
44
44
|
|
|
45
45
|
const done = visibleChars >= totalChars && totalChars > 0;
|
|
46
46
|
|
|
47
|
-
const cls = [
|
|
48
|
-
'macro-type',
|
|
49
|
-
done ? 'macro-type-done' : '',
|
|
50
|
-
className || '',
|
|
51
|
-
]
|
|
47
|
+
const cls = ['macro-type', done ? 'macro-type-done' : '', className || '']
|
|
52
48
|
.filter(Boolean)
|
|
53
49
|
.join(' ');
|
|
54
50
|
|
|
@@ -58,10 +54,7 @@ export function Type({ rawArgs, children, className, id }: TypeProps) {
|
|
|
58
54
|
class={cls}
|
|
59
55
|
ref={containerRef}
|
|
60
56
|
style={{
|
|
61
|
-
clipPath:
|
|
62
|
-
totalChars > 0 && !done
|
|
63
|
-
? undefined
|
|
64
|
-
: undefined,
|
|
57
|
+
clipPath: totalChars > 0 && !done ? undefined : undefined,
|
|
65
58
|
}}
|
|
66
59
|
>
|
|
67
60
|
<span
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { useLayoutEffect, useRef } from 'preact/hooks';
|
|
2
|
+
import {
|
|
3
|
+
registerAction,
|
|
4
|
+
generateActionId,
|
|
5
|
+
type ActionType,
|
|
6
|
+
type StoryAction,
|
|
7
|
+
} from '../action-registry';
|
|
8
|
+
|
|
9
|
+
export interface UseActionOptions {
|
|
10
|
+
type: ActionType;
|
|
11
|
+
key: string;
|
|
12
|
+
authorId?: string;
|
|
13
|
+
label: string;
|
|
14
|
+
target?: string;
|
|
15
|
+
variable?: string;
|
|
16
|
+
options?: string[];
|
|
17
|
+
value?: unknown;
|
|
18
|
+
disabled?: boolean;
|
|
19
|
+
perform: (value?: unknown) => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function useAction(opts: UseActionOptions): string {
|
|
23
|
+
const idRef = useRef<string>('');
|
|
24
|
+
|
|
25
|
+
// Generate ID only once on first call
|
|
26
|
+
if (!idRef.current) {
|
|
27
|
+
idRef.current = generateActionId(opts.type, opts.key, opts.authorId);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const id = idRef.current;
|
|
31
|
+
|
|
32
|
+
useLayoutEffect(() => {
|
|
33
|
+
const action: StoryAction = {
|
|
34
|
+
id,
|
|
35
|
+
type: opts.type,
|
|
36
|
+
label: opts.label,
|
|
37
|
+
perform: opts.perform,
|
|
38
|
+
};
|
|
39
|
+
if (opts.target !== undefined) action.target = opts.target;
|
|
40
|
+
if (opts.variable !== undefined) action.variable = opts.variable;
|
|
41
|
+
if (opts.options !== undefined) action.options = opts.options;
|
|
42
|
+
if (opts.value !== undefined) action.value = opts.value;
|
|
43
|
+
if (opts.disabled !== undefined) action.disabled = opts.disabled;
|
|
44
|
+
|
|
45
|
+
return registerAction(action);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
return id;
|
|
49
|
+
}
|
package/src/index.tsx
CHANGED
|
@@ -3,6 +3,7 @@ import { App } from './components/App';
|
|
|
3
3
|
import { parseStoryData } from './parser';
|
|
4
4
|
import { useStoryStore } from './store';
|
|
5
5
|
import { installStoryAPI } from './story-api';
|
|
6
|
+
import { resetIdCounters } from './action-registry';
|
|
6
7
|
import { executeStoryInit } from './story-init';
|
|
7
8
|
import {
|
|
8
9
|
parseStoryVariables,
|
|
@@ -93,11 +94,7 @@ function boot() {
|
|
|
93
94
|
const widgetTokens = tokenize(passage.content);
|
|
94
95
|
const widgetAST = buildAST(widgetTokens);
|
|
95
96
|
for (const node of widgetAST) {
|
|
96
|
-
if (
|
|
97
|
-
node.type === 'macro' &&
|
|
98
|
-
node.name === 'widget' &&
|
|
99
|
-
node.rawArgs
|
|
100
|
-
) {
|
|
97
|
+
if (node.type === 'macro' && node.name === 'widget' && node.rawArgs) {
|
|
101
98
|
const widgetName = node.rawArgs.trim().replace(/["']/g, '');
|
|
102
99
|
registerWidget(widgetName, node.children as ASTNode[]);
|
|
103
100
|
}
|
|
@@ -105,6 +102,15 @@ function boot() {
|
|
|
105
102
|
}
|
|
106
103
|
}
|
|
107
104
|
|
|
105
|
+
// Reset action ID counters on passage change
|
|
106
|
+
let prevPassage = '';
|
|
107
|
+
useStoryStore.subscribe((state) => {
|
|
108
|
+
if (state.currentPassage !== prevPassage) {
|
|
109
|
+
prevPassage = state.currentPassage;
|
|
110
|
+
resetIdCounters();
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
108
114
|
const root = document.getElementById('root');
|
|
109
115
|
if (!root) {
|
|
110
116
|
throw new Error('spindle: No <div id="root"> element found.');
|
package/src/markup/ast.ts
CHANGED
|
@@ -89,11 +89,7 @@ export function buildAST(tokens: Token[]): ASTNode[] {
|
|
|
89
89
|
if (stack.length === 0) return root;
|
|
90
90
|
const top = stack[stack.length - 1].node;
|
|
91
91
|
// For if-blocks, append to the last branch's children
|
|
92
|
-
if (
|
|
93
|
-
top.type === 'macro' &&
|
|
94
|
-
top.branches &&
|
|
95
|
-
top.branches.length > 0
|
|
96
|
-
) {
|
|
92
|
+
if (top.type === 'macro' && top.branches && top.branches.length > 0) {
|
|
97
93
|
return top.branches[top.branches.length - 1].children;
|
|
98
94
|
}
|
|
99
95
|
return top.children;
|
|
@@ -204,7 +200,8 @@ export function buildAST(tokens: Token[]): ASTNode[] {
|
|
|
204
200
|
// Handle branch macros (elseif/else, case/default, next)
|
|
205
201
|
if (BRANCH_PARENT[token.name]) {
|
|
206
202
|
const expectedParent = BRANCH_PARENT[token.name];
|
|
207
|
-
const topNode =
|
|
203
|
+
const topNode =
|
|
204
|
+
stack.length > 0 ? stack[stack.length - 1].node : null;
|
|
208
205
|
if (
|
|
209
206
|
!topNode ||
|
|
210
207
|
topNode.type !== 'macro' ||
|
package/src/markup/markdown.ts
CHANGED
package/src/markup/render.tsx
CHANGED
|
@@ -33,6 +33,7 @@ import { Stop } from '../components/macros/Stop';
|
|
|
33
33
|
import { Type } from '../components/macros/Type';
|
|
34
34
|
import { Widget } from '../components/macros/Widget';
|
|
35
35
|
import { Computed } from '../components/macros/Computed';
|
|
36
|
+
import { Meter } from '../components/macros/Meter';
|
|
36
37
|
import { getWidget } from '../widgets/widget-registry';
|
|
37
38
|
import { getMacro } from '../registry';
|
|
38
39
|
import { markdownToHtml } from './markdown';
|
|
@@ -119,6 +120,16 @@ function renderMacro(node: MacroNode, key: number) {
|
|
|
119
120
|
/>
|
|
120
121
|
);
|
|
121
122
|
|
|
123
|
+
case 'meter':
|
|
124
|
+
return (
|
|
125
|
+
<Meter
|
|
126
|
+
key={key}
|
|
127
|
+
rawArgs={node.rawArgs}
|
|
128
|
+
className={node.className}
|
|
129
|
+
id={node.id}
|
|
130
|
+
/>
|
|
131
|
+
);
|
|
132
|
+
|
|
122
133
|
case 'if':
|
|
123
134
|
return (
|
|
124
135
|
<If
|
|
@@ -371,9 +382,7 @@ function renderMacro(node: MacroNode, key: number) {
|
|
|
371
382
|
);
|
|
372
383
|
|
|
373
384
|
case 'stop':
|
|
374
|
-
return
|
|
375
|
-
<Stop key={key} />
|
|
376
|
-
);
|
|
385
|
+
return <Stop key={key} />;
|
|
377
386
|
|
|
378
387
|
case 'type':
|
|
379
388
|
return (
|
|
@@ -487,9 +496,7 @@ function renderSingleNode(
|
|
|
487
496
|
* Used for inline containers (button labels, link text) where block-level
|
|
488
497
|
* markdown (lists, headers) would misinterpret content like "-" or "+".
|
|
489
498
|
*/
|
|
490
|
-
export function renderInlineNodes(
|
|
491
|
-
nodes: ASTNode[],
|
|
492
|
-
): preact.ComponentChildren {
|
|
499
|
+
export function renderInlineNodes(nodes: ASTNode[]): preact.ComponentChildren {
|
|
493
500
|
if (nodes.length === 0) return null;
|
|
494
501
|
return nodes.map((node, i) => renderSingleNode(node, i));
|
|
495
502
|
}
|