@rohal12/spindle 0.16.1 → 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 -55
  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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rohal12/spindle",
3
- "version": "0.16.1",
3
+ "version": "0.17.0",
4
4
  "type": "module",
5
5
  "description": "A Preact-based story format for Twine 2.",
6
6
  "license": "Unlicense",
@@ -12,7 +12,8 @@ export type ActionType =
12
12
  | 'forward'
13
13
  | 'restart'
14
14
  | 'save'
15
- | 'load';
15
+ | 'load'
16
+ | 'dialog';
16
17
 
17
18
  export interface StoryAction {
18
19
  id: string;
@@ -0,0 +1,69 @@
1
+ import { createContext } from 'preact';
2
+ import { useMemo } from 'preact/hooks';
3
+ import { tokenize } from '../markup/tokenizer';
4
+ import { buildAST } from '../markup/ast';
5
+ import { renderNodes } from '../markup/render';
6
+ import { useStoryStore } from '../store';
7
+
8
+ export const DialogCloseContext = createContext<(() => void) | null>(null);
9
+
10
+ interface PassageDialogProps {
11
+ passageName?: string;
12
+ fallbackMarkup?: string;
13
+ panelClass?: string;
14
+ onClose: () => void;
15
+ }
16
+
17
+ export function PassageDialog({
18
+ passageName,
19
+ fallbackMarkup,
20
+ panelClass,
21
+ onClose,
22
+ }: PassageDialogProps) {
23
+ const storyData = useStoryStore((s) => s.storyData);
24
+
25
+ const passage = passageName
26
+ ? storyData?.passages.get(passageName)
27
+ : undefined;
28
+ const markup = passage?.content ?? fallbackMarkup;
29
+
30
+ const content = useMemo(() => {
31
+ if (!markup) {
32
+ return <div class="error">Dialog: no content available</div>;
33
+ }
34
+ try {
35
+ const tokens = tokenize(markup);
36
+ const ast = buildAST(tokens);
37
+ return renderNodes(ast);
38
+ } catch (err) {
39
+ return <div class="error">Error in dialog: {(err as Error).message}</div>;
40
+ }
41
+ }, [markup]);
42
+
43
+ const handleBackdrop = (e: MouseEvent) => {
44
+ if ((e.target as HTMLElement).classList.contains('dialog-overlay')) {
45
+ onClose();
46
+ }
47
+ };
48
+
49
+ const cls = panelClass ? `dialog-panel ${panelClass}` : 'dialog-panel';
50
+
51
+ return (
52
+ <DialogCloseContext.Provider value={onClose}>
53
+ <div
54
+ class="dialog-overlay"
55
+ onClick={handleBackdrop}
56
+ >
57
+ <div class={cls}>
58
+ <button
59
+ class="dialog-close"
60
+ onClick={onClose}
61
+ >
62
+ ✕
63
+ </button>
64
+ <div class="dialog-body">{content}</div>
65
+ </div>
66
+ </div>
67
+ </DialogCloseContext.Provider>
68
+ );
69
+ }
@@ -1,49 +1,36 @@
1
- import { useContext } from 'preact/hooks';
2
- import { renderInlineNodes, LocalsUpdateContext } from '../../markup/render';
3
- import { collectText } from '../../utils/extract-text';
4
- import { useAction } from '../../hooks/use-action';
5
- import { useInterpolate } from '../../hooks/use-interpolate';
6
- import { executeMutation } from '../../execute-mutation';
7
- import { currentSourceLocation } from '../../utils/source-location';
8
- import { registerMacro } from '../../registry';
9
- import type { MacroProps } from '../../registry';
1
+ import { defineMacro } from '../../define-macro';
10
2
 
11
- export function Button({ rawArgs, children = [], className, id }: MacroProps) {
12
- const resolve = useInterpolate();
13
- className = resolve(className);
14
- id = resolve(id);
15
- const { update, getValues } = useContext(LocalsUpdateContext);
3
+ defineMacro({
4
+ name: 'button',
5
+ interpolate: true,
6
+ render({ rawArgs, children = [] }, ctx) {
7
+ const handleClick = () => {
8
+ try {
9
+ ctx.mutate(rawArgs);
10
+ } catch (err) {
11
+ console.error(
12
+ `spindle: Error in {button ${rawArgs}}${ctx.sourceLocation()}:`,
13
+ err,
14
+ );
15
+ }
16
+ };
16
17
 
17
- const handleClick = () => {
18
- try {
19
- executeMutation(rawArgs, getValues(), update);
20
- } catch (err) {
21
- console.error(
22
- `spindle: Error in {button ${rawArgs}}${currentSourceLocation()}:`,
23
- err,
24
- );
25
- }
26
- };
18
+ ctx.useAction({
19
+ type: 'button',
20
+ key: rawArgs,
21
+ authorId: ctx.id,
22
+ label: ctx.collectText(children) || rawArgs,
23
+ perform: handleClick,
24
+ });
27
25
 
28
- useAction({
29
- type: 'button',
30
- key: rawArgs,
31
- authorId: id,
32
- label: collectText(children) || rawArgs,
33
- perform: handleClick,
34
- });
35
-
36
- const cls = className ? `macro-button ${className}` : 'macro-button';
37
-
38
- return (
39
- <button
40
- id={id}
41
- class={cls}
42
- onClick={handleClick}
43
- >
44
- {renderInlineNodes(children)}
45
- </button>
46
- );
47
- }
48
-
49
- registerMacro('button', Button);
26
+ return (
27
+ <button
28
+ id={ctx.id}
29
+ class={ctx.cls}
30
+ onClick={handleClick}
31
+ >
32
+ {ctx.renderInlineNodes(children)}
33
+ </button>
34
+ );
35
+ },
36
+ });
@@ -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): { varName: string; label: string } {
7
5
  const match = rawArgs.match(/^\s*(["']?\$\w+["']?)\s+["']?(.+?)["']?\s*$/);
@@ -13,38 +11,37 @@ function parseArgs(rawArgs: string): { varName: string; label: string } {
13
11
  return { varName, label };
14
12
  }
15
13
 
16
- export function Checkbox({ rawArgs, className, id }: MacroProps) {
17
- const { varName, label } = parseArgs(rawArgs);
18
- const name = varName.startsWith('$') ? varName.slice(1) : varName;
14
+ defineMacro({
15
+ name: 'checkbox',
16
+ render({ rawArgs }, ctx) {
17
+ const { varName, label } = parseArgs(rawArgs);
18
+ const name = varName.startsWith('$') ? varName.slice(1) : varName;
19
19
 
20
- const value = useStoryStore((s) => s.variables[name]);
21
- const setVariable = useStoryStore((s) => s.setVariable);
20
+ const value = useStoryStore((s) => s.variables[name]);
21
+ const setVariable = useStoryStore((s) => s.setVariable);
22
22
 
23
- useAction({
24
- type: 'checkbox',
25
- key: `$${name}`,
26
- authorId: id,
27
- label: label || name,
28
- variable: name,
29
- value: !!value,
30
- perform: (v) => setVariable(name, v !== undefined ? !!v : !value),
31
- });
23
+ ctx.useAction({
24
+ type: 'checkbox',
25
+ key: `$${name}`,
26
+ authorId: ctx.id,
27
+ label: label || name,
28
+ variable: name,
29
+ value: !!value,
30
+ perform: (v) => setVariable(name, v !== undefined ? !!v : !value),
31
+ });
32
32
 
33
- const cls = className ? `macro-checkbox ${className}` : 'macro-checkbox';
34
-
35
- return (
36
- <label
37
- id={id}
38
- class={cls}
39
- >
40
- <input
41
- type="checkbox"
42
- checked={!!value}
43
- onChange={() => setVariable(name, !value)}
44
- />
45
- {label ? ` ${label}` : null}
46
- </label>
47
- );
48
- }
49
-
50
- registerMacro('checkbox', Checkbox);
33
+ return (
34
+ <label
35
+ id={ctx.id}
36
+ class={ctx.cls}
37
+ >
38
+ <input
39
+ type="checkbox"
40
+ checked={!!value}
41
+ onChange={() => setVariable(name, !value)}
42
+ />
43
+ {label ? ` ${label}` : null}
44
+ </label>
45
+ );
46
+ },
47
+ });
@@ -1,15 +1,12 @@
1
- import { useLayoutEffect, useRef } from 'preact/hooks';
2
1
  import { useStoryStore } from '../../store';
3
2
  import { evaluate } from '../../expression';
4
- import { useMergedLocals } from '../../hooks/use-merged-locals';
5
3
  import { currentSourceLocation } from '../../utils/source-location';
6
- import { registerMacro } from '../../registry';
7
- import type { MacroProps } from '../../registry';
4
+ import { defineMacro } from '../../define-macro';
5
+ import { MacroError } from './MacroError';
8
6
 
9
7
  function parseComputedArgs(rawArgs: string): { target: string; expr: string } {
10
8
  const trimmed = rawArgs.trim();
11
9
 
12
- // Find the first '=' that isn't part of '==' or '!='
13
10
  let depth = 0;
14
11
  for (let i = 0; i < trimmed.length; i++) {
15
12
  const ch = trimmed[i];
@@ -85,56 +82,54 @@ function computeAndApply(
85
82
  }
86
83
  }
87
84
 
88
- export function Computed({ rawArgs }: MacroProps) {
89
- const [mergedVars, mergedTemps, mergedLocals] = useMergedLocals();
85
+ defineMacro({
86
+ name: 'computed',
87
+ merged: true,
88
+ render({ rawArgs }, ctx) {
89
+ const [mergedVars, mergedTemps, mergedLocals] = ctx.merged!;
90
90
 
91
- let target: string;
92
- let expr: string;
93
- try {
94
- ({ target, expr } = parseComputedArgs(rawArgs));
95
- } catch (err) {
96
- return (
97
- <span
98
- class="error"
99
- title={String(err)}
100
- >
101
- {`{computed error${currentSourceLocation()}: ${err instanceof Error ? err.message : String(err)}}`}
102
- </span>
103
- );
104
- }
105
- const isTemp = target.startsWith('_');
106
- const name = target.slice(1);
107
-
108
- // Synchronous first evaluation — sees preceding synchronous {set} mutations
109
- const ran = useRef(false);
110
- if (!ran.current) {
111
- ran.current = true;
112
- const state = useStoryStore.getState();
113
- computeAndApply(
114
- expr,
115
- name,
116
- isTemp,
117
- state.variables,
118
- state.temporary,
119
- mergedLocals,
120
- rawArgs,
121
- );
122
- }
91
+ let target: string;
92
+ let expr: string;
93
+ try {
94
+ ({ target, expr } = parseComputedArgs(rawArgs));
95
+ } catch (err) {
96
+ return (
97
+ <MacroError
98
+ macro="computed"
99
+ error={err}
100
+ />
101
+ );
102
+ }
103
+ const isTemp = target.startsWith('_');
104
+ const name = target.slice(1);
123
105
 
124
- // Reactive re-evaluation when dependencies change (via useMergedLocals subscription)
125
- useLayoutEffect(() => {
126
- computeAndApply(
127
- expr,
128
- name,
129
- isTemp,
130
- mergedVars,
131
- mergedTemps,
132
- mergedLocals,
133
- rawArgs,
134
- );
135
- }, [mergedVars, mergedTemps, mergedLocals]);
106
+ const ran = ctx.hooks.useRef(false);
107
+ if (!ran.current) {
108
+ ran.current = true;
109
+ const state = useStoryStore.getState();
110
+ computeAndApply(
111
+ expr,
112
+ name,
113
+ isTemp,
114
+ state.variables,
115
+ state.temporary,
116
+ mergedLocals,
117
+ rawArgs,
118
+ );
119
+ }
136
120
 
137
- return null;
138
- }
121
+ ctx.hooks.useLayoutEffect(() => {
122
+ computeAndApply(
123
+ expr,
124
+ name,
125
+ isTemp,
126
+ mergedVars,
127
+ mergedTemps,
128
+ mergedLocals,
129
+ rawArgs,
130
+ );
131
+ }, [mergedVars, mergedTemps, mergedLocals]);
139
132
 
140
- registerMacro('computed', Computed);
133
+ return null;
134
+ },
135
+ });
@@ -1,53 +1,43 @@
1
- import { useStoryStore } from '../../store';
2
- import { extractOptions } from './option-utils';
3
- import { useAction } from '../../hooks/use-action';
4
- import { registerMacro } from '../../registry';
5
- import type { MacroProps } from '../../registry';
6
-
7
- export function Cycle({ rawArgs, children = [], className, id }: MacroProps) {
8
- const varName = rawArgs.trim().replace(/["']/g, '');
9
- const name = varName.startsWith('$') ? varName.slice(1) : varName;
10
-
11
- const value = useStoryStore((s) => s.variables[name]);
12
- const setVariable = useStoryStore((s) => s.setVariable);
13
-
14
- const options = extractOptions(children);
15
-
16
- const handleClick = () => {
17
- if (options.length === 0) return;
18
- const currentIndex = options.indexOf(String(value));
19
- const nextIndex = (currentIndex + 1) % options.length;
20
- setVariable(name, options[nextIndex]);
21
- };
22
-
23
- useAction({
24
- type: 'cycle',
25
- key: `$${name}`,
26
- authorId: id,
27
- label: value == null ? options[0] || '' : String(value),
28
- variable: name,
29
- options,
30
- value,
31
- perform: (v) => {
32
- if (v !== undefined) {
33
- setVariable(name, v);
34
- } else {
35
- handleClick();
36
- }
37
- },
38
- });
39
-
40
- const cls = className ? `macro-cycle ${className}` : 'macro-cycle';
41
-
42
- return (
43
- <button
44
- id={id}
45
- class={cls}
46
- onClick={handleClick}
47
- >
48
- {value == null ? options[0] || '' : String(value)}
49
- </button>
50
- );
51
- }
52
-
53
- registerMacro('cycle', Cycle);
1
+ import { defineMacro } from '../../define-macro';
2
+
3
+ defineMacro({
4
+ name: 'cycle',
5
+ storeVar: true,
6
+ render({ children = [] }, ctx) {
7
+ const options = ctx.extractOptions(children);
8
+
9
+ const handleClick = () => {
10
+ if (options.length === 0) return;
11
+ const currentIndex = options.indexOf(String(ctx.value));
12
+ const nextIndex = (currentIndex + 1) % options.length;
13
+ ctx.setValue!(options[nextIndex]);
14
+ };
15
+
16
+ ctx.useAction({
17
+ type: 'cycle',
18
+ key: `$${ctx.varName}`,
19
+ authorId: ctx.id,
20
+ label: ctx.value == null ? options[0] || '' : String(ctx.value),
21
+ variable: ctx.varName,
22
+ options,
23
+ value: ctx.value,
24
+ perform: (v) => {
25
+ if (v !== undefined) {
26
+ ctx.setValue!(v);
27
+ } else {
28
+ handleClick();
29
+ }
30
+ },
31
+ });
32
+
33
+ return (
34
+ <button
35
+ id={ctx.id}
36
+ class={ctx.cls}
37
+ onClick={handleClick}
38
+ >
39
+ {ctx.value == null ? options[0] || '' : String(ctx.value)}
40
+ </button>
41
+ );
42
+ },
43
+ });
@@ -0,0 +1,40 @@
1
+ import { defineMacro } from '../../define-macro';
2
+ import { PassageDialog } from '../PassageDialog';
3
+
4
+ defineMacro({
5
+ name: 'dialog',
6
+ merged: true,
7
+ render({ rawArgs, children = [] }, ctx) {
8
+ const [open, setOpen] = ctx.hooks.useState(false);
9
+
10
+ let passageName: string;
11
+ try {
12
+ const result = ctx.evaluate!(rawArgs);
13
+ passageName = String(result);
14
+ } catch {
15
+ passageName = rawArgs.replace(/^["']|["']$/g, '').trim();
16
+ }
17
+
18
+ const label = children.length
19
+ ? ctx.renderInlineNodes(children)
20
+ : passageName;
21
+
22
+ return (
23
+ <>
24
+ <button
25
+ id={ctx.id}
26
+ class={ctx.cls}
27
+ onClick={() => setOpen(true)}
28
+ >
29
+ {label}
30
+ </button>
31
+ {open && (
32
+ <PassageDialog
33
+ passageName={passageName}
34
+ onClose={() => setOpen(false)}
35
+ />
36
+ )}
37
+ </>
38
+ );
39
+ },
40
+ });
@@ -1,24 +1,18 @@
1
- import { useLayoutEffect, useContext } from 'preact/hooks';
2
- import { LocalsUpdateContext } from '../../markup/render';
3
- import { executeMutation } from '../../execute-mutation';
4
- import { currentSourceLocation } from '../../utils/source-location';
5
- import { collectText } from '../../utils/extract-text';
6
- import { registerMacro } from '../../registry';
7
- import type { MacroProps } from '../../registry';
1
+ import { defineMacro } from '../../define-macro';
8
2
 
9
- export function Do({ children = [] }: MacroProps) {
10
- const code = collectText(children);
11
- const { update, getValues } = useContext(LocalsUpdateContext);
3
+ defineMacro({
4
+ name: 'do',
5
+ render({ children = [] }, ctx) {
6
+ const code = ctx.collectText(children);
12
7
 
13
- useLayoutEffect(() => {
14
- try {
15
- executeMutation(code, getValues(), update);
16
- } catch (err) {
17
- console.error(`spindle: Error in {do}${currentSourceLocation()}:`, err);
18
- }
19
- }, []);
8
+ ctx.hooks.useLayoutEffect(() => {
9
+ try {
10
+ ctx.mutate(code);
11
+ } catch (err) {
12
+ console.error(`spindle: Error in {do}${ctx.sourceLocation()}:`, err);
13
+ }
14
+ }, []);
20
15
 
21
- return null;
22
- }
23
-
24
- registerMacro('do', Do);
16
+ return null;
17
+ },
18
+ });