@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.
Files changed (65) hide show
  1. package/README.md +66 -0
  2. package/dist/pkg/format.js +1 -0
  3. package/dist/pkg/index.js +12 -0
  4. package/dist/pkg/types/globals.d.ts +18 -0
  5. package/dist/pkg/types/index.d.ts +158 -0
  6. package/package.json +71 -0
  7. package/src/components/App.tsx +53 -0
  8. package/src/components/Passage.tsx +36 -0
  9. package/src/components/PassageLink.tsx +35 -0
  10. package/src/components/SaveLoadDialog.tsx +403 -0
  11. package/src/components/SettingsDialog.tsx +106 -0
  12. package/src/components/StoryInterface.tsx +31 -0
  13. package/src/components/macros/Back.tsx +23 -0
  14. package/src/components/macros/Button.tsx +49 -0
  15. package/src/components/macros/Checkbox.tsx +41 -0
  16. package/src/components/macros/Computed.tsx +100 -0
  17. package/src/components/macros/Cycle.tsx +39 -0
  18. package/src/components/macros/Do.tsx +46 -0
  19. package/src/components/macros/For.tsx +113 -0
  20. package/src/components/macros/Forward.tsx +25 -0
  21. package/src/components/macros/Goto.tsx +23 -0
  22. package/src/components/macros/If.tsx +63 -0
  23. package/src/components/macros/Include.tsx +52 -0
  24. package/src/components/macros/Listbox.tsx +42 -0
  25. package/src/components/macros/MacroLink.tsx +107 -0
  26. package/src/components/macros/Numberbox.tsx +43 -0
  27. package/src/components/macros/Print.tsx +48 -0
  28. package/src/components/macros/QuickLoad.tsx +33 -0
  29. package/src/components/macros/QuickSave.tsx +22 -0
  30. package/src/components/macros/Radiobutton.tsx +59 -0
  31. package/src/components/macros/Repeat.tsx +53 -0
  32. package/src/components/macros/Restart.tsx +27 -0
  33. package/src/components/macros/Saves.tsx +25 -0
  34. package/src/components/macros/Set.tsx +36 -0
  35. package/src/components/macros/SettingsButton.tsx +29 -0
  36. package/src/components/macros/Stop.tsx +12 -0
  37. package/src/components/macros/StoryTitle.tsx +20 -0
  38. package/src/components/macros/Switch.tsx +69 -0
  39. package/src/components/macros/Textarea.tsx +41 -0
  40. package/src/components/macros/Textbox.tsx +40 -0
  41. package/src/components/macros/Timed.tsx +63 -0
  42. package/src/components/macros/Type.tsx +83 -0
  43. package/src/components/macros/Unset.tsx +25 -0
  44. package/src/components/macros/VarDisplay.tsx +44 -0
  45. package/src/components/macros/Widget.tsx +18 -0
  46. package/src/components/macros/option-utils.ts +14 -0
  47. package/src/expression.ts +93 -0
  48. package/src/index.tsx +120 -0
  49. package/src/markup/ast.ts +284 -0
  50. package/src/markup/markdown.ts +21 -0
  51. package/src/markup/render.tsx +537 -0
  52. package/src/markup/tokenizer.ts +581 -0
  53. package/src/parser.ts +72 -0
  54. package/src/registry.ts +21 -0
  55. package/src/saves/idb.ts +165 -0
  56. package/src/saves/save-manager.ts +317 -0
  57. package/src/saves/types.ts +40 -0
  58. package/src/settings.ts +96 -0
  59. package/src/store.ts +317 -0
  60. package/src/story-api.ts +129 -0
  61. package/src/story-init.ts +67 -0
  62. package/src/story-variables.ts +166 -0
  63. package/src/styles.css +780 -0
  64. package/src/utils/parse-delay.ts +14 -0
  65. 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
+ }