@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.
Files changed (41) hide show
  1. package/dist/pkg/format.js +1 -1
  2. package/package.json +5 -2
  3. package/src/action-registry.ts +83 -0
  4. package/src/automation/index.ts +11 -0
  5. package/src/automation/load-yaml.ts +20 -0
  6. package/src/automation/runner.ts +139 -0
  7. package/src/automation/types.ts +38 -0
  8. package/src/class-registry.ts +189 -0
  9. package/src/components/PassageLink.tsx +10 -0
  10. package/src/components/StoryInterface.tsx +2 -1
  11. package/src/components/macros/Back.tsx +10 -0
  12. package/src/components/macros/Button.tsx +13 -2
  13. package/src/components/macros/Checkbox.tsx +11 -0
  14. package/src/components/macros/Cycle.tsx +19 -1
  15. package/src/components/macros/Do.tsx +3 -2
  16. package/src/components/macros/Forward.tsx +10 -0
  17. package/src/components/macros/Listbox.tsx +15 -3
  18. package/src/components/macros/MacroLink.tsx +19 -8
  19. package/src/components/macros/Meter.tsx +130 -0
  20. package/src/components/macros/Numberbox.tsx +12 -3
  21. package/src/components/macros/QuickLoad.tsx +10 -0
  22. package/src/components/macros/QuickSave.tsx +9 -0
  23. package/src/components/macros/Radiobutton.tsx +11 -0
  24. package/src/components/macros/Restart.tsx +9 -0
  25. package/src/components/macros/Set.tsx +3 -2
  26. package/src/components/macros/Textarea.tsx +12 -3
  27. package/src/components/macros/Textbox.tsx +12 -3
  28. package/src/components/macros/Type.tsx +2 -9
  29. package/src/hooks/use-action.ts +49 -0
  30. package/src/index.tsx +11 -5
  31. package/src/markup/ast.ts +3 -6
  32. package/src/markup/markdown.ts +1 -4
  33. package/src/markup/render.tsx +13 -6
  34. package/src/markup/tokenizer.ts +66 -8
  35. package/src/saves/save-manager.ts +24 -4
  36. package/src/store.ts +17 -13
  37. package/src/story-api.ts +97 -0
  38. package/src/story-init.ts +3 -2
  39. package/src/story-variables.ts +3 -1
  40. package/src/styles.css +31 -1
  41. package/src/utils/extract-text.ts +8 -0
@@ -46,16 +46,74 @@ export interface HtmlToken {
46
46
  end: number;
47
47
  }
48
48
 
49
- export type Token = TextToken | LinkToken | MacroToken | VariableToken | HtmlToken;
49
+ export type Token =
50
+ | TextToken
51
+ | LinkToken
52
+ | MacroToken
53
+ | VariableToken
54
+ | HtmlToken;
50
55
 
51
56
  const HTML_TAGS = new Set([
52
- 'a', 'article', 'aside', 'b', 'blockquote', 'br', 'caption', 'code',
53
- 'col', 'colgroup', 'dd', 'del', 'details', 'dfn', 'div', 'dl', 'dt',
54
- 'em', 'figcaption', 'figure', 'footer', 'h1', 'h2', 'h3', 'h4', 'h5',
55
- 'h6', 'header', 'hr', 'i', 'img', 'ins', 'kbd', 'li', 'main', 'mark',
56
- 'nav', 'ol', 'p', 'pre', 'q', 's', 'samp', 'section', 'small', 'span',
57
- 'strong', 'sub', 'summary', 'sup', 'table', 'tbody', 'td', 'tfoot', 'th',
58
- 'thead', 'tr', 'u', 'ul', 'wbr',
57
+ 'a',
58
+ 'article',
59
+ 'aside',
60
+ 'b',
61
+ 'blockquote',
62
+ 'br',
63
+ 'caption',
64
+ 'code',
65
+ 'col',
66
+ 'colgroup',
67
+ 'dd',
68
+ 'del',
69
+ 'details',
70
+ 'dfn',
71
+ 'div',
72
+ 'dl',
73
+ 'dt',
74
+ 'em',
75
+ 'figcaption',
76
+ 'figure',
77
+ 'footer',
78
+ 'h1',
79
+ 'h2',
80
+ 'h3',
81
+ 'h4',
82
+ 'h5',
83
+ 'h6',
84
+ 'header',
85
+ 'hr',
86
+ 'i',
87
+ 'img',
88
+ 'ins',
89
+ 'kbd',
90
+ 'li',
91
+ 'main',
92
+ 'mark',
93
+ 'nav',
94
+ 'ol',
95
+ 'p',
96
+ 'pre',
97
+ 'q',
98
+ 's',
99
+ 'samp',
100
+ 'section',
101
+ 'small',
102
+ 'span',
103
+ 'strong',
104
+ 'sub',
105
+ 'summary',
106
+ 'sup',
107
+ 'table',
108
+ 'tbody',
109
+ 'td',
110
+ 'tfoot',
111
+ 'th',
112
+ 'thead',
113
+ 'tr',
114
+ 'u',
115
+ 'ul',
116
+ 'wbr',
59
117
  ]);
60
118
 
61
119
  const HTML_VOID_TAGS = new Set(['br', 'col', 'hr', 'img', 'wbr']);
@@ -15,6 +15,7 @@ import {
15
15
  getMeta,
16
16
  setMeta,
17
17
  } from './idb';
18
+ import { deepClone, serialize, deserialize } from '../class-registry';
18
19
 
19
20
  type TitleGenerator = (payload: SavePayload) => string;
20
21
 
@@ -118,7 +119,13 @@ export async function createSave(
118
119
  custom,
119
120
  };
120
121
 
121
- const record: SaveRecord = { meta, payload: structuredClone(payload) };
122
+ const serializedPayload = deepClone(payload);
123
+ serializedPayload.variables = serialize(serializedPayload.variables);
124
+ serializedPayload.history = serializedPayload.history.map((m) => ({
125
+ ...m,
126
+ variables: serialize(m.variables),
127
+ }));
128
+ const record: SaveRecord = { meta, payload: serializedPayload };
122
129
  await putSave(record);
123
130
  return record;
124
131
  }
@@ -132,7 +139,13 @@ export async function overwriteSave(
132
139
 
133
140
  existing.meta.updatedAt = new Date().toISOString();
134
141
  existing.meta.passage = payload.passage;
135
- existing.payload = structuredClone(payload);
142
+ const serializedPayload = deepClone(payload);
143
+ serializedPayload.variables = serialize(serializedPayload.variables);
144
+ serializedPayload.history = serializedPayload.history.map((m) => ({
145
+ ...m,
146
+ variables: serialize(m.variables),
147
+ }));
148
+ existing.payload = serializedPayload;
136
149
  await putSave(existing);
137
150
  return existing;
138
151
  }
@@ -141,7 +154,14 @@ export async function loadSave(
141
154
  saveId: string,
142
155
  ): Promise<SavePayload | undefined> {
143
156
  const record = await getSave(saveId);
144
- return record?.payload;
157
+ if (!record) return undefined;
158
+ const payload = record.payload;
159
+ payload.variables = deserialize(payload.variables);
160
+ payload.history = payload.history.map((m) => ({
161
+ ...m,
162
+ variables: deserialize(m.variables),
163
+ }));
164
+ return payload;
145
165
  }
146
166
 
147
167
  export async function deleteSaveById(saveId: string): Promise<void> {
@@ -294,7 +314,7 @@ export async function importSave(
294
314
  }
295
315
 
296
316
  // Re-assign a new ID to avoid collisions
297
- const record = structuredClone(data.save);
317
+ const record = deepClone(data.save);
298
318
  record.meta.id = crypto.randomUUID();
299
319
  record.meta.updatedAt = new Date().toISOString();
300
320
 
package/src/store.ts CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  quickSave,
11
11
  loadQuickSave,
12
12
  } from './saves/save-manager';
13
+ import { deepClone, deserialize } from './class-registry';
13
14
 
14
15
  export interface HistoryMoment {
15
16
  passage: string;
@@ -75,7 +76,7 @@ export const useStoryStore = create<StoryState>()(
75
76
  );
76
77
  }
77
78
 
78
- const initialVars = structuredClone(variableDefaults);
79
+ const initialVars = deepClone(variableDefaults);
79
80
 
80
81
  set((state) => {
81
82
  state.storyData = storyData as StoryData;
@@ -86,7 +87,7 @@ export const useStoryStore = create<StoryState>()(
86
87
  state.history = [
87
88
  {
88
89
  passage: startPassage.name,
89
- variables: structuredClone(initialVars),
90
+ variables: deepClone(initialVars),
90
91
  timestamp: Date.now(),
91
92
  },
92
93
  ];
@@ -130,7 +131,7 @@ export const useStoryStore = create<StoryState>()(
130
131
 
131
132
  state.history.push({
132
133
  passage: passageName,
133
- variables: { ...state.variables },
134
+ variables: deepClone(state.variables),
134
135
  timestamp: Date.now(),
135
136
  });
136
137
  state.historyIndex = state.history.length - 1;
@@ -147,7 +148,7 @@ export const useStoryStore = create<StoryState>()(
147
148
  state.historyIndex--;
148
149
  const moment = state.history[state.historyIndex];
149
150
  state.currentPassage = moment.passage;
150
- state.variables = { ...moment.variables };
151
+ state.variables = deepClone(moment.variables);
151
152
  state.temporary = {};
152
153
  });
153
154
  },
@@ -158,7 +159,7 @@ export const useStoryStore = create<StoryState>()(
158
159
  state.historyIndex++;
159
160
  const moment = state.history[state.historyIndex];
160
161
  state.currentPassage = moment.passage;
161
- state.variables = { ...moment.variables };
162
+ state.variables = deepClone(moment.variables);
162
163
  state.temporary = {};
163
164
  });
164
165
  },
@@ -201,7 +202,7 @@ export const useStoryStore = create<StoryState>()(
201
202
  const startPassage = storyData.passagesById.get(storyData.startNode);
202
203
  if (!startPassage) return;
203
204
 
204
- const initialVars = structuredClone(variableDefaults);
205
+ const initialVars = deepClone(variableDefaults);
205
206
 
206
207
  set((state) => {
207
208
  state.currentPassage = startPassage.name;
@@ -210,7 +211,7 @@ export const useStoryStore = create<StoryState>()(
210
211
  state.history = [
211
212
  {
212
213
  passage: startPassage.name,
213
- variables: structuredClone(initialVars),
214
+ variables: deepClone(initialVars),
214
215
  timestamp: Date.now(),
215
216
  },
216
217
  ];
@@ -244,8 +245,8 @@ export const useStoryStore = create<StoryState>()(
244
245
 
245
246
  const payload: SavePayload = {
246
247
  passage: currentPassage,
247
- variables: structuredClone(variables),
248
- history: structuredClone(history),
248
+ variables: deepClone(variables),
249
+ history: deepClone(history),
249
250
  historyIndex,
250
251
  visitCounts: { ...visitCounts },
251
252
  renderCounts: { ...renderCounts },
@@ -294,8 +295,8 @@ export const useStoryStore = create<StoryState>()(
294
295
  } = get();
295
296
  return {
296
297
  passage: currentPassage,
297
- variables: structuredClone(variables),
298
- history: structuredClone(history),
298
+ variables: deepClone(variables),
299
+ history: deepClone(history),
299
300
  historyIndex,
300
301
  visitCounts: { ...visitCounts },
301
302
  renderCounts: { ...renderCounts },
@@ -305,8 +306,11 @@ export const useStoryStore = create<StoryState>()(
305
306
  loadFromPayload: (payload: SavePayload) => {
306
307
  set((state) => {
307
308
  state.currentPassage = payload.passage;
308
- state.variables = payload.variables;
309
- state.history = payload.history;
309
+ state.variables = deserialize(payload.variables);
310
+ state.history = payload.history.map((m) => ({
311
+ ...m,
312
+ variables: deserialize(m.variables),
313
+ }));
310
314
  state.historyIndex = payload.historyIndex;
311
315
  state.visitCounts = payload.visitCounts ?? {};
312
316
  state.renderCounts = payload.renderCounts ?? {};
package/src/story-api.ts CHANGED
@@ -2,6 +2,21 @@ import { useStoryStore } from './store';
2
2
  import { settings } from './settings';
3
3
  import type { SavePayload } from './saves/types';
4
4
  import { setTitleGenerator } from './saves/save-manager';
5
+ import { registerClass } from './class-registry';
6
+ import {
7
+ getActions,
8
+ getAction,
9
+ onActionsChanged,
10
+ type StoryAction,
11
+ } from './action-registry';
12
+
13
+ export type { StoryAction };
14
+
15
+ type NavigateCallback = (to: string, from: string) => void;
16
+ type ActionsChangedCallback = () => void;
17
+ type VariableChangedCallback = (
18
+ changed: Record<string, { from: unknown; to: unknown }>,
19
+ ) => void;
5
20
 
6
21
  export interface StoryAPI {
7
22
  get(name: string): unknown;
@@ -23,10 +38,18 @@ export interface StoryAPI {
23
38
  hasRenderedAny(...names: string[]): boolean;
24
39
  hasRenderedAll(...names: string[]): boolean;
25
40
  readonly title: string;
41
+ readonly passage: string;
26
42
  readonly settings: typeof settings;
43
+ registerClass(name: string, ctor: new (...args: any[]) => any): void;
27
44
  readonly saves: {
28
45
  setTitleGenerator(fn: (payload: SavePayload) => string): void;
29
46
  };
47
+ getActions(): StoryAction[];
48
+ performAction(id: string, value?: unknown): void;
49
+ on(event: 'navigate', callback: NavigateCallback): () => void;
50
+ on(event: 'actionsChanged', callback: ActionsChangedCallback): () => void;
51
+ on(event: 'variableChanged', callback: VariableChangedCallback): () => void;
52
+ waitForActions(): Promise<StoryAction[]>;
30
53
  }
31
54
 
32
55
  function createStoryAPI(): StoryAPI {
@@ -114,13 +137,87 @@ function createStoryAPI(): StoryAPI {
114
137
  return useStoryStore.getState().storyData?.name || '';
115
138
  },
116
139
 
140
+ get passage(): string {
141
+ return useStoryStore.getState().currentPassage;
142
+ },
143
+
117
144
  settings,
118
145
 
146
+ registerClass(name: string, ctor: new (...args: any[]) => any): void {
147
+ registerClass(name, ctor);
148
+ },
149
+
119
150
  saves: {
120
151
  setTitleGenerator(fn: (payload: SavePayload) => string): void {
121
152
  setTitleGenerator(fn);
122
153
  },
123
154
  },
155
+
156
+ getActions(): StoryAction[] {
157
+ return getActions();
158
+ },
159
+
160
+ performAction(id: string, value?: unknown): void {
161
+ const action = getAction(id);
162
+ if (!action) {
163
+ throw new Error(`spindle: Action "${id}" not found.`);
164
+ }
165
+ if (action.disabled) {
166
+ throw new Error(`spindle: Action "${id}" is disabled.`);
167
+ }
168
+ action.perform(value);
169
+ },
170
+
171
+ on(event: string, callback: (...args: any[]) => void): () => void {
172
+ if (event === 'navigate') {
173
+ let prev = useStoryStore.getState().currentPassage;
174
+ return useStoryStore.subscribe((state) => {
175
+ if (state.currentPassage !== prev) {
176
+ const from = prev;
177
+ prev = state.currentPassage;
178
+ (callback as NavigateCallback)(state.currentPassage, from);
179
+ }
180
+ });
181
+ }
182
+
183
+ if (event === 'actionsChanged') {
184
+ return onActionsChanged(callback as ActionsChangedCallback);
185
+ }
186
+
187
+ if (event === 'variableChanged') {
188
+ let prevVars = { ...useStoryStore.getState().variables };
189
+ return useStoryStore.subscribe((state) => {
190
+ const changed: Record<string, { from: unknown; to: unknown }> = {};
191
+ let hasChanges = false;
192
+ const allKeys = new Set([
193
+ ...Object.keys(prevVars),
194
+ ...Object.keys(state.variables),
195
+ ]);
196
+ for (const key of allKeys) {
197
+ if (state.variables[key] !== prevVars[key]) {
198
+ changed[key] = { from: prevVars[key], to: state.variables[key] };
199
+ hasChanges = true;
200
+ }
201
+ }
202
+ prevVars = { ...state.variables };
203
+ if (hasChanges) {
204
+ (callback as VariableChangedCallback)(changed);
205
+ }
206
+ });
207
+ }
208
+
209
+ throw new Error(`spindle: Unknown event "${event}".`);
210
+ },
211
+
212
+ waitForActions(): Promise<StoryAction[]> {
213
+ return new Promise((resolve) => {
214
+ requestAnimationFrame(() => {
215
+ requestAnimationFrame(() => {
216
+ resolve(getActions());
217
+ });
218
+ });
219
+ });
220
+ },
124
221
  };
125
222
  }
126
223
 
package/src/story-init.ts CHANGED
@@ -4,6 +4,7 @@ import { buildAST } from './markup/ast';
4
4
  import { execute } from './expression';
5
5
  import type { ASTNode } from './markup/ast';
6
6
  import { setSaveTitlePassage } from './saves/save-manager';
7
+ import { deepClone } from './class-registry';
7
8
 
8
9
  /**
9
10
  * Walk AST nodes from StoryInit and execute {set} and {do} imperatively
@@ -42,8 +43,8 @@ export function executeStoryInit() {
42
43
  const tokens = tokenize(storyInit.content);
43
44
  const ast = buildAST(tokens);
44
45
 
45
- const vars = { ...state.variables };
46
- const temps = { ...state.temporary };
46
+ const vars = deepClone(state.variables);
47
+ const temps = deepClone(state.temporary);
47
48
 
48
49
  walkAndExecute(ast, vars, temps);
49
50
 
@@ -114,7 +114,9 @@ function validateRef(
114
114
  }
115
115
  const fieldSchema = current.fields.get(parts[i]);
116
116
  if (!fieldSchema) {
117
- return `Undeclared field: $${parts.slice(0, i + 1).join('.')}`;
117
+ // Unknown fields on objects are allowed — classes registered via
118
+ // Story.registerClass() can add methods/getters not in the defaults.
119
+ return null;
118
120
  }
119
121
  current = fieldSchema;
120
122
  }
package/src/styles.css CHANGED
@@ -629,6 +629,36 @@ tw-storydata {
629
629
  border-color: #90caf9;
630
630
  }
631
631
 
632
+ /* Macro: meter */
633
+
634
+ .macro-meter {
635
+ position: relative;
636
+ width: 100%;
637
+ height: 1.4em;
638
+ background: #2a2a3e;
639
+ border-radius: 0.3em;
640
+ overflow: hidden;
641
+ border: 1px solid #444;
642
+ }
643
+
644
+ .macro-meter-fill {
645
+ height: 100%;
646
+ background: #64b5f6;
647
+ transition: width 0.3s ease;
648
+ }
649
+
650
+ .macro-meter-label {
651
+ position: absolute;
652
+ inset: 0;
653
+ display: flex;
654
+ align-items: center;
655
+ justify-content: center;
656
+ font-size: 0.85em;
657
+ color: #e0e0e0;
658
+ text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8);
659
+ pointer-events: none;
660
+ }
661
+
632
662
  /* Markdown wrapper — invisible to layout */
633
663
 
634
664
  .md {
@@ -777,4 +807,4 @@ tw-storydata {
777
807
  50% {
778
808
  opacity: 0;
779
809
  }
780
- }
810
+ }
@@ -0,0 +1,8 @@
1
+ import type { ASTNode } from '../markup/ast';
2
+
3
+ /**
4
+ * Collect plain text from AST nodes, ignoring non-text nodes.
5
+ */
6
+ export function collectText(nodes: ASTNode[]): string {
7
+ return nodes.map((n) => (n.type === 'text' ? n.value : '')).join('');
8
+ }