@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.
- package/dist/pkg/format.js +1 -1
- package/package.json +5 -2
- package/src/action-registry.ts +83 -0
- package/src/automation/index.ts +11 -0
- package/src/automation/load-yaml.ts +20 -0
- package/src/automation/runner.ts +139 -0
- package/src/automation/types.ts +38 -0
- package/src/class-registry.ts +189 -0
- package/src/components/PassageLink.tsx +10 -0
- package/src/components/StoryInterface.tsx +2 -1
- package/src/components/macros/Back.tsx +10 -0
- package/src/components/macros/Button.tsx +13 -2
- package/src/components/macros/Checkbox.tsx +11 -0
- package/src/components/macros/Cycle.tsx +19 -1
- package/src/components/macros/Do.tsx +3 -2
- package/src/components/macros/Forward.tsx +10 -0
- package/src/components/macros/Listbox.tsx +15 -3
- package/src/components/macros/MacroLink.tsx +19 -8
- package/src/components/macros/Meter.tsx +130 -0
- package/src/components/macros/Numberbox.tsx +12 -3
- package/src/components/macros/QuickLoad.tsx +10 -0
- package/src/components/macros/QuickSave.tsx +9 -0
- package/src/components/macros/Radiobutton.tsx +11 -0
- package/src/components/macros/Restart.tsx +9 -0
- package/src/components/macros/Set.tsx +3 -2
- package/src/components/macros/Textarea.tsx +12 -3
- package/src/components/macros/Textbox.tsx +12 -3
- package/src/components/macros/Type.tsx +2 -9
- package/src/hooks/use-action.ts +49 -0
- package/src/index.tsx +11 -5
- package/src/markup/ast.ts +3 -6
- package/src/markup/markdown.ts +1 -4
- package/src/markup/render.tsx +13 -6
- package/src/markup/tokenizer.ts +66 -8
- package/src/saves/save-manager.ts +24 -4
- package/src/store.ts +17 -13
- package/src/story-api.ts +97 -0
- package/src/story-init.ts +3 -2
- package/src/story-variables.ts +3 -1
- package/src/styles.css +31 -1
- package/src/utils/extract-text.ts +8 -0
package/src/markup/tokenizer.ts
CHANGED
|
@@ -46,16 +46,74 @@ export interface HtmlToken {
|
|
|
46
46
|
end: number;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
export type Token =
|
|
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',
|
|
53
|
-
'
|
|
54
|
-
'
|
|
55
|
-
'
|
|
56
|
-
'
|
|
57
|
-
'
|
|
58
|
-
'
|
|
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
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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 =
|
|
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:
|
|
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:
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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:
|
|
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:
|
|
248
|
-
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:
|
|
298
|
-
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 =
|
|
46
|
-
const temps =
|
|
46
|
+
const vars = deepClone(state.variables);
|
|
47
|
+
const temps = deepClone(state.temporary);
|
|
47
48
|
|
|
48
49
|
walkAndExecute(ast, vars, temps);
|
|
49
50
|
|
package/src/story-variables.ts
CHANGED
|
@@ -114,7 +114,9 @@ function validateRef(
|
|
|
114
114
|
}
|
|
115
115
|
const fieldSchema = current.fields.get(parts[i]);
|
|
116
116
|
if (!fieldSchema) {
|
|
117
|
-
|
|
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
|
+
}
|