@rohal12/spindle 0.2.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.
@@ -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,
@@ -101,6 +102,15 @@ function boot() {
101
102
  }
102
103
  }
103
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
+
104
114
  const root = document.getElementById('root');
105
115
  if (!root) {
106
116
  throw new Error('spindle: No <div id="root"> element found.');
package/src/story-api.ts CHANGED
@@ -3,6 +3,20 @@ import { settings } from './settings';
3
3
  import type { SavePayload } from './saves/types';
4
4
  import { setTitleGenerator } from './saves/save-manager';
5
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;
6
20
 
7
21
  export interface StoryAPI {
8
22
  get(name: string): unknown;
@@ -24,11 +38,18 @@ export interface StoryAPI {
24
38
  hasRenderedAny(...names: string[]): boolean;
25
39
  hasRenderedAll(...names: string[]): boolean;
26
40
  readonly title: string;
41
+ readonly passage: string;
27
42
  readonly settings: typeof settings;
28
43
  registerClass(name: string, ctor: new (...args: any[]) => any): void;
29
44
  readonly saves: {
30
45
  setTitleGenerator(fn: (payload: SavePayload) => string): void;
31
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[]>;
32
53
  }
33
54
 
34
55
  function createStoryAPI(): StoryAPI {
@@ -116,6 +137,10 @@ function createStoryAPI(): StoryAPI {
116
137
  return useStoryStore.getState().storyData?.name || '';
117
138
  },
118
139
 
140
+ get passage(): string {
141
+ return useStoryStore.getState().currentPassage;
142
+ },
143
+
119
144
  settings,
120
145
 
121
146
  registerClass(name: string, ctor: new (...args: any[]) => any): void {
@@ -127,6 +152,72 @@ function createStoryAPI(): StoryAPI {
127
152
  setTitleGenerator(fn);
128
153
  },
129
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
+ },
130
221
  };
131
222
  }
132
223
 
@@ -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
  }
@@ -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
+ }