@rohal12/spindle 0.16.0 → 0.17.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 (52) hide show
  1. package/dist/pkg/format.js +1 -1
  2. package/package.json +1 -1
  3. package/src/action-registry.ts +2 -1
  4. package/src/components/PassageDialog.tsx +69 -0
  5. package/src/components/macros/Button.tsx +33 -46
  6. package/src/components/macros/Checkbox.tsx +32 -35
  7. package/src/components/macros/Computed.tsx +49 -54
  8. package/src/components/macros/Cycle.tsx +43 -53
  9. package/src/components/macros/Dialog.tsx +40 -0
  10. package/src/components/macros/Do.tsx +15 -21
  11. package/src/components/macros/For.tsx +57 -75
  12. package/src/components/macros/Goto.tsx +18 -22
  13. package/src/components/macros/If.tsx +39 -53
  14. package/src/components/macros/Include.tsx +30 -46
  15. package/src/components/macros/Listbox.tsx +38 -48
  16. package/src/components/macros/MacroError.tsx +18 -0
  17. package/src/components/macros/MacroLink.tsx +36 -51
  18. package/src/components/macros/MenubarButtons.tsx +158 -0
  19. package/src/components/macros/Meter.tsx +35 -52
  20. package/src/components/macros/Numberbox.tsx +30 -47
  21. package/src/components/macros/PassageDisplay.tsx +26 -29
  22. package/src/components/macros/Print.tsx +19 -36
  23. package/src/components/macros/Radiobutton.tsx +33 -38
  24. package/src/components/macros/Repeat.tsx +49 -54
  25. package/src/components/macros/SaveManager.tsx +400 -0
  26. package/src/components/macros/Set.tsx +19 -24
  27. package/src/components/{SettingsDialog.tsx → macros/SettingsControls.tsx} +18 -36
  28. package/src/components/macros/Stop.tsx +11 -12
  29. package/src/components/macros/StoryTitle.tsx +16 -16
  30. package/src/components/macros/Switch.tsx +41 -59
  31. package/src/components/macros/Textarea.tsx +26 -45
  32. package/src/components/macros/Textbox.tsx +27 -44
  33. package/src/components/macros/Timed.tsx +52 -63
  34. package/src/components/macros/Type.tsx +61 -62
  35. package/src/components/macros/Unset.tsx +21 -22
  36. package/src/components/macros/Widget.tsx +11 -15
  37. package/src/components/macros/option-utils.ts +13 -0
  38. package/src/define-macro.ts +154 -0
  39. package/src/macros/register-builtins.ts +4 -7
  40. package/src/markup/ast.ts +10 -16
  41. package/src/markup/render.tsx +0 -13
  42. package/src/story-api.ts +7 -0
  43. package/src/styles.css +37 -68
  44. package/src/components/PassageLink.tsx +0 -49
  45. package/src/components/SaveLoadDialog.tsx +0 -408
  46. package/src/components/macros/Back.tsx +0 -32
  47. package/src/components/macros/Forward.tsx +0 -34
  48. package/src/components/macros/QuickLoad.tsx +0 -42
  49. package/src/components/macros/QuickSave.tsx +0 -30
  50. package/src/components/macros/Restart.tsx +0 -35
  51. package/src/components/macros/Saves.tsx +0 -24
  52. package/src/components/macros/SettingsButton.tsx +0 -28
@@ -0,0 +1,158 @@
1
+ import { useState } from 'preact/hooks';
2
+ import { useStoryStore } from '../../store';
3
+ import { settings } from '../../settings';
4
+ import { defineMacro } from '../../define-macro';
5
+ import { PassageDialog } from '../PassageDialog';
6
+ import type { ActionType } from '../../action-registry';
7
+ import type { ComponentChildren } from 'preact';
8
+
9
+ interface MenubarActionConfig {
10
+ name: string;
11
+ label: ComponentChildren;
12
+ actionType: ActionType;
13
+ title?: string;
14
+ confirm?: string;
15
+ hidden?: () => boolean;
16
+ setup?: () => { perform: () => void; disabled?: boolean };
17
+ dialog?: {
18
+ passageName: string;
19
+ fallbackMarkup?: string;
20
+ panelClass?: string;
21
+ };
22
+ }
23
+
24
+ export function defineMenubarAction(config: MenubarActionConfig) {
25
+ defineMacro({
26
+ name: config.name,
27
+ render(_, ctx) {
28
+ if (config.hidden?.()) return null;
29
+
30
+ const [dialogOpen, setDialogOpen] = config.dialog
31
+ ? useState(false)
32
+ : [false, undefined as never];
33
+
34
+ const setup = config.setup?.() ?? { perform: () => {} };
35
+ const { disabled } = setup;
36
+
37
+ const cls = ctx.className
38
+ ? `menubar-button ${ctx.className}`
39
+ : 'menubar-button';
40
+
41
+ const perform = config.dialog
42
+ ? () => setDialogOpen(true)
43
+ : config.confirm
44
+ ? () => {
45
+ if (confirm(config.confirm!)) setup.perform();
46
+ }
47
+ : setup.perform;
48
+
49
+ ctx.useAction({
50
+ type: config.actionType,
51
+ key: config.name,
52
+ authorId: ctx.id,
53
+ label: typeof config.label === 'string' ? config.label : config.name,
54
+ disabled,
55
+ perform: config.dialog ? () => setDialogOpen(true) : setup.perform,
56
+ });
57
+
58
+ return (
59
+ <>
60
+ <button
61
+ id={ctx.id}
62
+ class={cls}
63
+ onClick={perform}
64
+ disabled={disabled}
65
+ title={config.title}
66
+ >
67
+ {config.label}
68
+ </button>
69
+ {config.dialog && dialogOpen && (
70
+ <PassageDialog
71
+ passageName={config.dialog.passageName}
72
+ fallbackMarkup={config.dialog.fallbackMarkup}
73
+ panelClass={config.dialog.panelClass}
74
+ onClose={() => setDialogOpen(false)}
75
+ />
76
+ )}
77
+ </>
78
+ );
79
+ },
80
+ });
81
+ }
82
+
83
+ defineMenubarAction({
84
+ name: 'back',
85
+ label: '← Back',
86
+ actionType: 'back',
87
+ setup: () => ({
88
+ perform: useStoryStore((s) => s.goBack),
89
+ disabled: !useStoryStore((s) => s.historyIndex > 0),
90
+ }),
91
+ });
92
+
93
+ defineMenubarAction({
94
+ name: 'forward',
95
+ label: 'Forward →',
96
+ actionType: 'forward',
97
+ setup: () => ({
98
+ perform: useStoryStore((s) => s.goForward),
99
+ disabled: !useStoryStore((s) => s.historyIndex < s.history.length - 1),
100
+ }),
101
+ });
102
+
103
+ defineMenubarAction({
104
+ name: 'quicksave',
105
+ label: 'QuickSave',
106
+ actionType: 'save',
107
+ title: 'Quick Save (F6)',
108
+ setup: () => ({
109
+ perform: useStoryStore((s) => s.save),
110
+ }),
111
+ });
112
+
113
+ defineMenubarAction({
114
+ name: 'quickload',
115
+ label: 'QuickLoad',
116
+ actionType: 'load',
117
+ title: 'Quick Load (F9)',
118
+ confirm: 'Load saved game? Current progress will be lost.',
119
+ setup: () => {
120
+ const load = useStoryStore((s) => s.load);
121
+ const hasSave = useStoryStore((s) => s.hasSave);
122
+ useStoryStore((s) => s.saveVersion);
123
+ return { perform: () => load(), disabled: !hasSave() };
124
+ },
125
+ });
126
+
127
+ defineMenubarAction({
128
+ name: 'restart',
129
+ label: '↺ Restart',
130
+ actionType: 'restart',
131
+ confirm: 'Restart the story? All progress will be lost.',
132
+ setup: () => ({
133
+ perform: useStoryStore((s) => s.restart),
134
+ }),
135
+ });
136
+
137
+ defineMenubarAction({
138
+ name: 'saves',
139
+ label: 'Saves',
140
+ actionType: 'dialog',
141
+ dialog: {
142
+ passageName: 'SaveLoad',
143
+ fallbackMarkup: '{save-manager}',
144
+ panelClass: 'dialog-saves',
145
+ },
146
+ });
147
+
148
+ defineMenubarAction({
149
+ name: 'settings',
150
+ label: 'Settings',
151
+ actionType: 'dialog',
152
+ hidden: () => !settings.hasAny(),
153
+ dialog: {
154
+ passageName: 'Settings',
155
+ fallbackMarkup: '{settings-controls}',
156
+ panelClass: 'dialog-settings',
157
+ },
158
+ });
@@ -1,13 +1,6 @@
1
- import { evaluate } from '../../expression';
2
- import { useMergedLocals } from '../../hooks/use-merged-locals';
3
- import { useInterpolate } from '../../hooks/use-interpolate';
4
- import { registerMacro } from '../../registry';
5
- import type { MacroProps } from '../../registry';
1
+ import { defineMacro } from '../../define-macro';
2
+ import { MacroError } from './MacroError';
6
3
 
7
- /**
8
- * Parse rawArgs into currentExpr, maxExpr, and optional labelMode.
9
- * Supports: {meter $current $max}, {meter $current $max "%"}, etc.
10
- */
11
4
  function parseArgs(rawArgs: string): {
12
5
  currentExpr: string;
13
6
  maxExpr: string;
@@ -25,8 +18,6 @@ function parseArgs(rawArgs: string): {
25
18
  }
26
19
 
27
20
  // Split remaining into two expressions.
28
- // Expressions can contain dots, brackets, parens — we need to find the split point.
29
- // Strategy: walk tokens, splitting on whitespace that isn't inside parens/brackets.
30
21
  const exprs = splitExpressions(rest);
31
22
  if (exprs.length < 2) {
32
23
  throw new Error(
@@ -79,48 +70,40 @@ function formatLabel(
79
70
  return `${current} / ${max}`;
80
71
  }
81
72
 
82
- export function Meter({ rawArgs, className, id }: MacroProps) {
83
- const resolve = useInterpolate();
84
- className = resolve(className);
85
- id = resolve(id);
86
- const [mergedVars, mergedTemps, mergedLocals] = useMergedLocals();
73
+ defineMacro({
74
+ name: 'meter',
75
+ interpolate: true,
76
+ merged: true,
77
+ render({ rawArgs }, ctx) {
78
+ try {
79
+ const { currentExpr, maxExpr, labelMode } = parseArgs(rawArgs);
80
+ const current = Number(ctx.evaluate!(currentExpr));
81
+ const max = Number(ctx.evaluate!(maxExpr));
82
+ const pct =
83
+ max === 0 ? 0 : Math.max(0, Math.min(100, (current / max) * 100));
84
+ const label = formatLabel(current, max, labelMode);
87
85
 
88
- try {
89
- const { currentExpr, maxExpr, labelMode } = parseArgs(rawArgs);
90
- const current = Number(
91
- evaluate(currentExpr, mergedVars, mergedTemps, mergedLocals),
92
- );
93
- const max = Number(
94
- evaluate(maxExpr, mergedVars, mergedTemps, mergedLocals),
95
- );
96
- const pct =
97
- max === 0 ? 0 : Math.max(0, Math.min(100, (current / max) * 100));
98
- const label = formatLabel(current, max, labelMode);
99
-
100
- const classes = ['macro-meter', className].filter(Boolean).join(' ');
86
+ const classes = ctx.cls;
101
87
 
102
- return (
103
- <div
104
- class={classes}
105
- id={id}
106
- >
88
+ return (
107
89
  <div
108
- class="macro-meter-fill"
109
- style={`width: ${pct}%`}
90
+ class={classes}
91
+ id={ctx.id}
92
+ >
93
+ <div
94
+ class="macro-meter-fill"
95
+ style={`width: ${pct}%`}
96
+ />
97
+ {label != null && <span class="macro-meter-label">{label}</span>}
98
+ </div>
99
+ );
100
+ } catch (err) {
101
+ return (
102
+ <MacroError
103
+ macro="meter"
104
+ error={err}
110
105
  />
111
- {label != null && <span class="macro-meter-label">{label}</span>}
112
- </div>
113
- );
114
- } catch (err) {
115
- return (
116
- <span
117
- class="error"
118
- title={String(err)}
119
- >
120
- {`{meter error: ${err instanceof Error ? err.message : String(err)}}`}
121
- </span>
122
- );
123
- }
124
- }
125
-
126
- registerMacro('meter', Meter);
106
+ );
107
+ }
108
+ },
109
+ });
@@ -1,50 +1,33 @@
1
- import { useStoryStore } from '../../store';
2
- import { useAction } from '../../hooks/use-action';
3
- import { registerMacro } from '../../registry';
4
- import type { MacroProps } from '../../registry';
1
+ import { defineMacro } from '../../define-macro';
5
2
 
6
- function parseArgs(rawArgs: string): { varName: string; placeholder: string } {
7
- const match = rawArgs.match(/^\s*(["']?\$\w+["']?)\s*(?:["'](.*)["'])?\s*$/);
8
- if (!match) {
9
- return { varName: rawArgs.trim(), placeholder: '' };
10
- }
11
- const varName = match[1]!.replace(/["']/g, '');
12
- const placeholder = match[2] || '';
13
- return { varName, placeholder };
14
- }
3
+ defineMacro({
4
+ name: 'numberbox',
5
+ storeVar: true,
6
+ render({ rawArgs }, ctx) {
7
+ const { placeholder } = ctx.parseVarArgs(rawArgs);
15
8
 
16
- export function Numberbox({ rawArgs, className, id }: MacroProps) {
17
- const { varName, placeholder } = parseArgs(rawArgs);
18
- const name = varName.startsWith('$') ? varName.slice(1) : varName;
9
+ ctx.useAction({
10
+ type: 'numberbox',
11
+ key: `$${ctx.varName}`,
12
+ authorId: ctx.id,
13
+ label: placeholder || ctx.varName!,
14
+ variable: ctx.varName,
15
+ value: ctx.value,
16
+ perform: (v) => ctx.setValue!(v !== undefined ? Number(v) : 0),
17
+ });
19
18
 
20
- const value = useStoryStore((s) => s.variables[name]);
21
- const setVariable = useStoryStore((s) => s.setVariable);
22
-
23
- useAction({
24
- type: 'numberbox',
25
- key: `$${name}`,
26
- authorId: id,
27
- label: placeholder || name,
28
- variable: name,
29
- value,
30
- perform: (v) => setVariable(name, v !== undefined ? Number(v) : 0),
31
- });
32
-
33
- const cls = className ? `macro-numberbox ${className}` : 'macro-numberbox';
34
-
35
- return (
36
- <input
37
- type="number"
38
- id={id}
39
- class={cls}
40
- value={value == null ? '' : String(value)}
41
- placeholder={placeholder}
42
- onInput={(e) => {
43
- const val = (e.target as HTMLInputElement).value;
44
- setVariable(name, val === '' ? 0 : Number(val));
45
- }}
46
- />
47
- );
48
- }
49
-
50
- registerMacro('numberbox', Numberbox);
19
+ return (
20
+ <input
21
+ type="number"
22
+ id={ctx.id}
23
+ class={ctx.cls}
24
+ value={ctx.value == null ? '' : String(ctx.value)}
25
+ placeholder={placeholder}
26
+ onInput={(e) => {
27
+ const val = (e.target as HTMLInputElement).value;
28
+ ctx.setValue!(val === '' ? 0 : Number(val));
29
+ }}
30
+ />
31
+ );
32
+ },
33
+ });
@@ -1,36 +1,33 @@
1
1
  import { useStoryStore } from '../../store';
2
2
  import { Passage } from '../Passage';
3
- import { useInterpolate } from '../../hooks/use-interpolate';
4
- import { registerMacro } from '../../registry';
5
- import type { MacroProps } from '../../registry';
3
+ import { defineMacro } from '../../define-macro';
6
4
 
7
- export function PassageDisplay({ className, id }: MacroProps) {
8
- const resolve = useInterpolate();
9
- className = resolve(className);
10
- id = resolve(id);
11
- const currentPassage = useStoryStore((s) => s.currentPassage);
12
- const storyData = useStoryStore((s) => s.storyData);
5
+ defineMacro({
6
+ name: 'passage',
7
+ interpolate: true,
8
+ render(_props, ctx) {
9
+ const currentPassage = useStoryStore((s) => s.currentPassage);
10
+ const storyData = useStoryStore((s) => s.storyData);
11
+
12
+ const passage = storyData?.passages.get(currentPassage);
13
+ if (!passage) {
14
+ return (
15
+ <div class="error">
16
+ Error: Passage &ldquo;{currentPassage}&rdquo; not found.
17
+ </div>
18
+ );
19
+ }
13
20
 
14
- const passage = storyData?.passages.get(currentPassage);
15
- if (!passage) {
16
21
  return (
17
- <div class="error">
18
- Error: Passage &ldquo;{currentPassage}&rdquo; not found.
22
+ <div
23
+ id={ctx.id ?? 'story'}
24
+ class={ctx.className ?? 'story'}
25
+ >
26
+ <Passage
27
+ passage={passage}
28
+ key={currentPassage}
29
+ />
19
30
  </div>
20
31
  );
21
- }
22
-
23
- return (
24
- <div
25
- id={id ?? 'story'}
26
- class={className ?? 'story'}
27
- >
28
- <Passage
29
- passage={passage}
30
- key={currentPassage}
31
- />
32
- </div>
33
- );
34
- }
35
-
36
- registerMacro('passage', PassageDisplay);
32
+ },
33
+ });
@@ -1,39 +1,22 @@
1
- import { evaluate } from '../../expression';
2
- import { useMergedLocals } from '../../hooks/use-merged-locals';
3
- import { useInterpolate } from '../../hooks/use-interpolate';
4
- import { currentSourceLocation } from '../../utils/source-location';
5
- import { registerMacro } from '../../registry';
6
- import type { MacroProps } from '../../registry';
1
+ import { defineMacro } from '../../define-macro';
2
+ import { MacroError } from './MacroError';
7
3
 
8
- export function Print({ rawArgs, className, id }: MacroProps) {
9
- const resolve = useInterpolate();
10
- className = resolve(className);
11
- id = resolve(id);
12
- const [mergedVars, mergedTemps, mergedLocals] = useMergedLocals();
13
-
14
- try {
15
- const result = evaluate(rawArgs, mergedVars, mergedTemps, mergedLocals);
16
- const display = result == null ? '' : String(result);
17
- if (className || id)
4
+ defineMacro({
5
+ name: 'print',
6
+ interpolate: true,
7
+ merged: true,
8
+ render({ rawArgs }, ctx) {
9
+ try {
10
+ const result = ctx.evaluate!(rawArgs);
11
+ const display = result == null ? '' : String(result);
12
+ return ctx.wrap(display);
13
+ } catch (err) {
18
14
  return (
19
- <span
20
- id={id}
21
- class={className}
22
- >
23
- {display}
24
- </span>
15
+ <MacroError
16
+ macro="print"
17
+ error={err}
18
+ />
25
19
  );
26
- return <>{display}</>;
27
- } catch (err) {
28
- return (
29
- <span
30
- class="error"
31
- title={String(err)}
32
- >
33
- {`{print error${currentSourceLocation()}: ${err instanceof Error ? err.message : String(err)}}`}
34
- </span>
35
- );
36
- }
37
- }
38
-
39
- registerMacro('print', Print);
20
+ }
21
+ },
22
+ });
@@ -1,7 +1,5 @@
1
1
  import { useStoryStore } from '../../store';
2
- import { useAction } from '../../hooks/use-action';
3
- import { registerMacro } from '../../registry';
4
- import type { MacroProps } from '../../registry';
2
+ import { defineMacro } from '../../define-macro';
5
3
 
6
4
  function parseArgs(rawArgs: string): {
7
5
  varName: string;
@@ -28,41 +26,38 @@ function parseArgs(rawArgs: string): {
28
26
  };
29
27
  }
30
28
 
31
- export function Radiobutton({ rawArgs, className, id }: MacroProps) {
32
- const { varName, value: radioValue, label } = parseArgs(rawArgs);
33
- const name = varName.startsWith('$') ? varName.slice(1) : varName;
29
+ defineMacro({
30
+ name: 'radiobutton',
31
+ render({ rawArgs }, ctx) {
32
+ const { varName, value: radioValue, label } = parseArgs(rawArgs);
33
+ const name = varName.startsWith('$') ? varName.slice(1) : varName;
34
34
 
35
- const currentValue = useStoryStore((s) => s.variables[name]);
36
- const setVariable = useStoryStore((s) => s.setVariable);
35
+ const currentValue = useStoryStore((s) => s.variables[name]);
36
+ const setVariable = useStoryStore((s) => s.setVariable);
37
37
 
38
- useAction({
39
- type: 'radiobutton',
40
- key: `$${name}:${radioValue}`,
41
- authorId: id,
42
- label: label || radioValue,
43
- variable: name,
44
- value: currentValue,
45
- perform: () => setVariable(name, radioValue),
46
- });
38
+ ctx.useAction({
39
+ type: 'radiobutton',
40
+ key: `$${name}:${radioValue}`,
41
+ authorId: ctx.id,
42
+ label: label || radioValue,
43
+ variable: name,
44
+ value: currentValue,
45
+ perform: () => setVariable(name, radioValue),
46
+ });
47
47
 
48
- const cls = className
49
- ? `macro-radiobutton ${className}`
50
- : 'macro-radiobutton';
51
-
52
- return (
53
- <label
54
- id={id}
55
- class={cls}
56
- >
57
- <input
58
- type="radio"
59
- name={`radio-${name}`}
60
- checked={currentValue === radioValue}
61
- onChange={() => setVariable(name, radioValue)}
62
- />
63
- {label ? ` ${label}` : null}
64
- </label>
65
- );
66
- }
67
-
68
- registerMacro('radiobutton', Radiobutton);
48
+ return (
49
+ <label
50
+ id={ctx.id}
51
+ class={ctx.cls}
52
+ >
53
+ <input
54
+ type="radio"
55
+ name={`radio-${name}`}
56
+ checked={currentValue === radioValue}
57
+ onChange={() => setVariable(name, radioValue)}
58
+ />
59
+ {label ? ` ${label}` : null}
60
+ </label>
61
+ );
62
+ },
63
+ });
@@ -1,65 +1,60 @@
1
1
  import { createContext } from 'preact';
2
- import { useState, useEffect, useCallback, useMemo } from 'preact/hooks';
3
- import { renderNodes } from '../../markup/render';
4
2
  import { parseDelay } from '../../utils/parse-delay';
5
3
  import { hasInterpolation, interpolate } from '../../interpolation';
6
4
  import { useStoryStore } from '../../store';
7
- import { registerMacro } from '../../registry';
8
- import type { MacroProps } from '../../registry';
5
+ import { defineMacro } from '../../define-macro';
9
6
 
10
7
  export const RepeatContext = createContext<{ stop: () => void }>({
11
8
  stop: () => {},
12
9
  });
13
10
 
14
- export function Repeat({
15
- rawArgs,
16
- children = [],
17
- className: rawClassName,
18
- id: rawId,
19
- }: MacroProps) {
20
- const [className, id] = useMemo(() => {
21
- const resolveOnce = (s: string | undefined) => {
22
- if (!s || !hasInterpolation(s)) return s;
23
- const st = useStoryStore.getState();
24
- return interpolate(s, st.variables, st.temporary, {});
25
- };
26
- return [resolveOnce(rawClassName), resolveOnce(rawId)];
27
- }, [rawClassName, rawId]);
28
-
29
- const delay = parseDelay(rawArgs);
30
- const [count, setCount] = useState(0);
31
- const [stopped, setStopped] = useState(false);
32
-
33
- const stop = useCallback(() => setStopped(true), []);
34
-
35
- useEffect(() => {
36
- if (stopped) return;
37
- const interval = setInterval(() => {
38
- setCount((c) => c + 1);
39
- }, delay);
40
- return () => clearInterval(interval);
41
- }, [delay, stopped]);
42
-
43
- if (count === 0 && !stopped) return null;
44
-
45
- const cls = className ? `macro-repeat ${className}` : undefined;
46
-
47
- const content = (
48
- <RepeatContext.Provider value={{ stop }}>
49
- <span key={count}>{renderNodes(children)}</span>
50
- </RepeatContext.Provider>
51
- );
52
-
53
- if (cls || id)
54
- return (
55
- <span
56
- id={id}
57
- class={cls}
58
- >
59
- {content}
60
- </span>
11
+ defineMacro({
12
+ name: 'repeat',
13
+ render({ rawArgs, children = [], className: rawClassName, id: rawId }, ctx) {
14
+ const { useState, useEffect, useCallback, useMemo } = ctx.hooks;
15
+
16
+ const [className, id] = useMemo(() => {
17
+ const resolveOnce = (s: string | undefined) => {
18
+ if (!s || !hasInterpolation(s)) return s;
19
+ const st = useStoryStore.getState();
20
+ return interpolate(s, st.variables, st.temporary, {});
21
+ };
22
+ return [resolveOnce(rawClassName), resolveOnce(rawId)];
23
+ }, [rawClassName, rawId]);
24
+
25
+ const delay = parseDelay(rawArgs);
26
+ const [count, setCount] = useState(0);
27
+ const [stopped, setStopped] = useState(false);
28
+
29
+ const stop = useCallback(() => setStopped(true), []);
30
+
31
+ useEffect(() => {
32
+ if (stopped) return;
33
+ const interval = setInterval(() => {
34
+ setCount((c) => c + 1);
35
+ }, delay);
36
+ return () => clearInterval(interval);
37
+ }, [delay, stopped]);
38
+
39
+ if (count === 0 && !stopped) return null;
40
+
41
+ const cls = className ? `macro-repeat ${className}` : undefined;
42
+
43
+ const content = (
44
+ <RepeatContext.Provider value={{ stop }}>
45
+ <span key={count}>{ctx.renderNodes(children)}</span>
46
+ </RepeatContext.Provider>
61
47
  );
62
- return content;
63
- }
64
48
 
65
- registerMacro('repeat', Repeat);
49
+ if (cls || id)
50
+ return (
51
+ <span
52
+ id={id}
53
+ class={cls}
54
+ >
55
+ {content}
56
+ </span>
57
+ );
58
+ return content;
59
+ },
60
+ });