@rohal12/spindle 0.16.1 → 0.18.1
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 +2 -2
- package/src/action-registry.ts +2 -1
- package/src/components/Passage.tsx +69 -4
- package/src/components/PassageDialog.tsx +69 -0
- package/src/components/StoryInterface.tsx +2 -2
- package/src/components/macros/Button.tsx +33 -46
- package/src/components/macros/Checkbox.tsx +32 -35
- package/src/components/macros/Computed.tsx +49 -54
- package/src/components/macros/Cycle.tsx +43 -53
- package/src/components/macros/Dialog.tsx +40 -0
- package/src/components/macros/Do.tsx +15 -21
- package/src/components/macros/For.tsx +57 -75
- package/src/components/macros/Goto.tsx +18 -22
- package/src/components/macros/If.tsx +39 -53
- package/src/components/macros/Include.tsx +30 -46
- package/src/components/macros/Listbox.tsx +38 -48
- package/src/components/macros/MacroError.tsx +18 -0
- package/src/components/macros/MacroLink.tsx +38 -53
- package/src/components/macros/MenubarButtons.tsx +158 -0
- package/src/components/macros/Meter.tsx +35 -52
- package/src/components/macros/Numberbox.tsx +30 -47
- package/src/components/macros/PassageDisplay.tsx +37 -30
- package/src/components/macros/Print.tsx +19 -36
- package/src/components/macros/Radiobutton.tsx +33 -38
- package/src/components/macros/Repeat.tsx +49 -54
- package/src/components/macros/SaveManager.tsx +400 -0
- package/src/components/macros/Set.tsx +19 -24
- package/src/components/{SettingsDialog.tsx → macros/SettingsControls.tsx} +18 -36
- package/src/components/macros/Span.tsx +8 -0
- package/src/components/macros/Stop.tsx +11 -12
- package/src/components/macros/StoryTitle.tsx +16 -16
- package/src/components/macros/Switch.tsx +41 -59
- package/src/components/macros/Textarea.tsx +26 -45
- package/src/components/macros/Textbox.tsx +27 -44
- package/src/components/macros/Timed.tsx +54 -55
- package/src/components/macros/Type.tsx +61 -62
- package/src/components/macros/Unset.tsx +21 -22
- package/src/components/macros/Widget.tsx +11 -15
- package/src/components/macros/option-utils.ts +13 -0
- package/src/define-macro.ts +154 -0
- package/src/expression.ts +10 -8
- package/src/index.tsx +7 -0
- package/src/macros/register-builtins.ts +5 -7
- package/src/markup/ast.ts +11 -16
- package/src/markup/render.tsx +0 -13
- package/src/saves/save-manager.ts +43 -0
- package/src/saves/types.ts +9 -2
- package/src/store.ts +283 -52
- package/src/story-api.ts +33 -12
- package/src/styles.css +37 -68
- package/src/components/PassageLink.tsx +0 -49
- package/src/components/SaveLoadDialog.tsx +0 -408
- package/src/components/macros/Back.tsx +0 -32
- package/src/components/macros/Forward.tsx +0 -34
- package/src/components/macros/QuickLoad.tsx +0 -42
- package/src/components/macros/QuickSave.tsx +0 -30
- package/src/components/macros/Restart.tsx +0 -35
- package/src/components/macros/Saves.tsx +0 -24
- package/src/components/macros/SettingsButton.tsx +0 -28
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { h, Fragment } from 'preact';
|
|
2
|
+
import type { VNode, ComponentChildren } from 'preact';
|
|
3
|
+
import {
|
|
4
|
+
useContext,
|
|
5
|
+
useState,
|
|
6
|
+
useRef,
|
|
7
|
+
useEffect,
|
|
8
|
+
useLayoutEffect,
|
|
9
|
+
useCallback,
|
|
10
|
+
useMemo,
|
|
11
|
+
} from 'preact/hooks';
|
|
12
|
+
import { useInterpolate } from './hooks/use-interpolate';
|
|
13
|
+
import { useMergedLocals } from './hooks/use-merged-locals';
|
|
14
|
+
import {
|
|
15
|
+
LocalsUpdateContext,
|
|
16
|
+
renderNodes,
|
|
17
|
+
renderInlineNodes,
|
|
18
|
+
} from './markup/render';
|
|
19
|
+
import { executeMutation } from './execute-mutation';
|
|
20
|
+
import { evaluate } from './expression';
|
|
21
|
+
import { useStoryStore } from './store';
|
|
22
|
+
import { useAction } from './hooks/use-action';
|
|
23
|
+
import type { UseActionOptions } from './hooks/use-action';
|
|
24
|
+
import { collectText } from './utils/extract-text';
|
|
25
|
+
import { currentSourceLocation } from './utils/source-location';
|
|
26
|
+
import { parseVarArgs, extractOptions } from './components/macros/option-utils';
|
|
27
|
+
import { registerMacro, registerSubMacro } from './registry';
|
|
28
|
+
import type { MacroProps } from './registry';
|
|
29
|
+
|
|
30
|
+
export function macroClass(type: string, className?: string): string {
|
|
31
|
+
const base = `macro-${type}`;
|
|
32
|
+
return className ? `${base} ${className}` : base;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface MacroContext {
|
|
36
|
+
className?: string;
|
|
37
|
+
id?: string;
|
|
38
|
+
resolve?: (s: string | undefined) => string | undefined;
|
|
39
|
+
cls: string;
|
|
40
|
+
mutate: (code: string) => void;
|
|
41
|
+
update: (key: string, value: unknown) => void;
|
|
42
|
+
getValues: () => Record<string, unknown>;
|
|
43
|
+
merged?: readonly [
|
|
44
|
+
Record<string, unknown>,
|
|
45
|
+
Record<string, unknown>,
|
|
46
|
+
Record<string, unknown>,
|
|
47
|
+
];
|
|
48
|
+
varName?: string;
|
|
49
|
+
value?: unknown;
|
|
50
|
+
setValue?: (value: unknown) => void;
|
|
51
|
+
evaluate?: (expr: string) => unknown;
|
|
52
|
+
collectText: typeof collectText;
|
|
53
|
+
sourceLocation: typeof currentSourceLocation;
|
|
54
|
+
parseVarArgs: typeof parseVarArgs;
|
|
55
|
+
extractOptions: typeof extractOptions;
|
|
56
|
+
wrap: (content: ComponentChildren) => VNode<any>;
|
|
57
|
+
useAction: (opts: UseActionOptions) => string;
|
|
58
|
+
h: typeof h;
|
|
59
|
+
renderNodes: typeof renderNodes;
|
|
60
|
+
renderInlineNodes: typeof renderInlineNodes;
|
|
61
|
+
hooks: {
|
|
62
|
+
useState: typeof useState;
|
|
63
|
+
useRef: typeof useRef;
|
|
64
|
+
useEffect: typeof useEffect;
|
|
65
|
+
useLayoutEffect: typeof useLayoutEffect;
|
|
66
|
+
useCallback: typeof useCallback;
|
|
67
|
+
useMemo: typeof useMemo;
|
|
68
|
+
useContext: typeof useContext;
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface MacroDefinition {
|
|
73
|
+
name: string;
|
|
74
|
+
subMacros?: string[];
|
|
75
|
+
interpolate?: boolean;
|
|
76
|
+
merged?: boolean;
|
|
77
|
+
storeVar?: boolean;
|
|
78
|
+
render: (props: MacroProps, ctx: MacroContext) => VNode | null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const sharedHooks = {
|
|
82
|
+
useState,
|
|
83
|
+
useRef,
|
|
84
|
+
useEffect,
|
|
85
|
+
useLayoutEffect,
|
|
86
|
+
useCallback,
|
|
87
|
+
useMemo,
|
|
88
|
+
useContext,
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export function defineMacro(config: MacroDefinition): void {
|
|
92
|
+
function Wrapper(props: MacroProps) {
|
|
93
|
+
// className/id resolved first (interpolate may transform them)
|
|
94
|
+
let className = props.className;
|
|
95
|
+
let id = props.id;
|
|
96
|
+
let resolve: ((s: string | undefined) => string | undefined) | undefined;
|
|
97
|
+
if (config.interpolate) {
|
|
98
|
+
resolve = useInterpolate();
|
|
99
|
+
className = resolve(className);
|
|
100
|
+
id = resolve(id);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Always-on: cssClass + mutation
|
|
104
|
+
const { update, getValues } = useContext(LocalsUpdateContext);
|
|
105
|
+
const ctx: MacroContext = {
|
|
106
|
+
collectText,
|
|
107
|
+
sourceLocation: currentSourceLocation,
|
|
108
|
+
parseVarArgs,
|
|
109
|
+
extractOptions,
|
|
110
|
+
h,
|
|
111
|
+
renderNodes,
|
|
112
|
+
renderInlineNodes,
|
|
113
|
+
hooks: sharedHooks,
|
|
114
|
+
useAction,
|
|
115
|
+
className,
|
|
116
|
+
id,
|
|
117
|
+
resolve,
|
|
118
|
+
cls: macroClass(config.name, className),
|
|
119
|
+
mutate: (code: string) => executeMutation(code, getValues(), update),
|
|
120
|
+
update,
|
|
121
|
+
getValues,
|
|
122
|
+
wrap: undefined as any,
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
if (config.merged) {
|
|
126
|
+
ctx.merged = useMergedLocals();
|
|
127
|
+
const merged = ctx.merged;
|
|
128
|
+
ctx.evaluate = (expr: string) =>
|
|
129
|
+
evaluate(expr, merged[0], merged[1], merged[2]);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (config.storeVar) {
|
|
133
|
+
const firstToken = props.rawArgs.trim().split(/[\s"']+/)[0] ?? '';
|
|
134
|
+
const varName = firstToken.replace(/["']/g, '').replace(/^\$/, '');
|
|
135
|
+
ctx.varName = varName;
|
|
136
|
+
ctx.value = useStoryStore((s) => s.variables[varName]);
|
|
137
|
+
const setVariable = useStoryStore((s) => s.setVariable);
|
|
138
|
+
ctx.setValue = (value: unknown) => setVariable(varName, value);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
ctx.wrap = (content: ComponentChildren): VNode<any> => {
|
|
142
|
+
if (ctx.className || ctx.id)
|
|
143
|
+
return h('span', { id: ctx.id, class: ctx.className }, content);
|
|
144
|
+
return h(Fragment, null, content);
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
return config.render(props, ctx);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
registerMacro(config.name, Wrapper);
|
|
151
|
+
if (config.subMacros) {
|
|
152
|
+
for (const sub of config.subMacros) registerSubMacro(sub);
|
|
153
|
+
}
|
|
154
|
+
}
|
package/src/expression.ts
CHANGED
|
@@ -6,12 +6,12 @@ import { random, randomInt } from './prng';
|
|
|
6
6
|
interface ExpressionFns {
|
|
7
7
|
currentPassage: () => Passage | undefined;
|
|
8
8
|
previousPassage: () => Passage | undefined;
|
|
9
|
-
visited: (name
|
|
10
|
-
hasVisited: (name
|
|
9
|
+
visited: (name?: string) => number;
|
|
10
|
+
hasVisited: (name?: string) => boolean;
|
|
11
11
|
hasVisitedAny: (...names: string[]) => boolean;
|
|
12
12
|
hasVisitedAll: (...names: string[]) => boolean;
|
|
13
|
-
rendered: (name
|
|
14
|
-
hasRendered: (name
|
|
13
|
+
rendered: (name?: string) => number;
|
|
14
|
+
hasRendered: (name?: string) => boolean;
|
|
15
15
|
hasRenderedAny: (...names: string[]) => boolean;
|
|
16
16
|
hasRenderedAll: (...names: string[]) => boolean;
|
|
17
17
|
random: () => number;
|
|
@@ -191,15 +191,17 @@ export function buildExpressionFns() {
|
|
|
191
191
|
return cachedFns;
|
|
192
192
|
}
|
|
193
193
|
|
|
194
|
-
const visited = (name
|
|
195
|
-
|
|
194
|
+
const visited = (name?: string): number =>
|
|
195
|
+
visitCounts[name ?? useStoryStore.getState().currentPassage] ?? 0;
|
|
196
|
+
const hasVisited = (name?: string): boolean => visited(name) > 0;
|
|
196
197
|
const hasVisitedAny = (...names: string[]): boolean =>
|
|
197
198
|
names.some((n) => visited(n) > 0);
|
|
198
199
|
const hasVisitedAll = (...names: string[]): boolean =>
|
|
199
200
|
names.every((n) => visited(n) > 0);
|
|
200
201
|
|
|
201
|
-
const rendered = (name
|
|
202
|
-
|
|
202
|
+
const rendered = (name?: string): number =>
|
|
203
|
+
renderCounts[name ?? useStoryStore.getState().currentPassage] ?? 0;
|
|
204
|
+
const hasRendered = (name?: string): boolean => rendered(name) > 0;
|
|
203
205
|
const hasRenderedAny = (...names: string[]): boolean =>
|
|
204
206
|
names.some((n) => rendered(n) > 0);
|
|
205
207
|
const hasRenderedAll = (...names: string[]): boolean =>
|
package/src/index.tsx
CHANGED
|
@@ -5,6 +5,7 @@ import { useStoryStore } from './store';
|
|
|
5
5
|
import { installStoryAPI } from './story-api';
|
|
6
6
|
import { resetIdCounters } from './action-registry';
|
|
7
7
|
import { executeStoryInit } from './story-init';
|
|
8
|
+
import { loadSession } from './saves/save-manager';
|
|
8
9
|
import {
|
|
9
10
|
parseStoryVariables,
|
|
10
11
|
validatePassages,
|
|
@@ -89,6 +90,12 @@ function boot() {
|
|
|
89
90
|
// Execute StoryInit passage if it exists
|
|
90
91
|
executeStoryInit();
|
|
91
92
|
|
|
93
|
+
// Restore session state if the page was refreshed
|
|
94
|
+
const sessionPayload = loadSession(storyData.ifid);
|
|
95
|
+
if (sessionPayload) {
|
|
96
|
+
useStoryStore.getState().loadFromPayload(sessionPayload);
|
|
97
|
+
}
|
|
98
|
+
|
|
92
99
|
// Register widgets from passages tagged "widget"
|
|
93
100
|
for (const [, passage] of storyData.passages) {
|
|
94
101
|
if (passage.tags.includes('widget')) {
|
|
@@ -15,13 +15,10 @@ import '../components/macros/Stop';
|
|
|
15
15
|
import '../components/macros/Type';
|
|
16
16
|
import '../components/macros/Widget';
|
|
17
17
|
import '../components/macros/StoryTitle';
|
|
18
|
-
import '../components/macros/
|
|
19
|
-
import '../components/macros/
|
|
20
|
-
import '../components/macros/
|
|
21
|
-
import '../components/macros/
|
|
22
|
-
import '../components/macros/QuickLoad';
|
|
23
|
-
import '../components/macros/SettingsButton';
|
|
24
|
-
import '../components/macros/Saves';
|
|
18
|
+
import '../components/macros/MenubarButtons';
|
|
19
|
+
import '../components/macros/Dialog';
|
|
20
|
+
import '../components/macros/SaveManager';
|
|
21
|
+
import '../components/macros/SettingsControls';
|
|
25
22
|
import '../components/macros/Include';
|
|
26
23
|
import '../components/macros/Goto';
|
|
27
24
|
import '../components/macros/Unset';
|
|
@@ -32,4 +29,5 @@ import '../components/macros/Checkbox';
|
|
|
32
29
|
import '../components/macros/Radiobutton';
|
|
33
30
|
import '../components/macros/Listbox';
|
|
34
31
|
import '../components/macros/Cycle';
|
|
32
|
+
import '../components/macros/Span';
|
|
35
33
|
import '../components/macros/PassageDisplay';
|
package/src/markup/ast.ts
CHANGED
|
@@ -5,14 +5,6 @@ export interface TextNode {
|
|
|
5
5
|
value: string;
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
-
export interface LinkNode {
|
|
9
|
-
type: 'link';
|
|
10
|
-
display: string;
|
|
11
|
-
target: string;
|
|
12
|
-
className?: string;
|
|
13
|
-
id?: string;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
8
|
export interface VariableNode {
|
|
17
9
|
type: 'variable';
|
|
18
10
|
name: string;
|
|
@@ -45,7 +37,7 @@ export interface HtmlNode {
|
|
|
45
37
|
children: ASTNode[];
|
|
46
38
|
}
|
|
47
39
|
|
|
48
|
-
export type ASTNode = TextNode |
|
|
40
|
+
export type ASTNode = TextNode | VariableNode | MacroNode | HtmlNode;
|
|
49
41
|
|
|
50
42
|
/** Macros that require a closing tag and can contain children */
|
|
51
43
|
const BLOCK_MACROS = new Set([
|
|
@@ -61,6 +53,7 @@ const BLOCK_MACROS = new Set([
|
|
|
61
53
|
'repeat',
|
|
62
54
|
'type',
|
|
63
55
|
'widget',
|
|
56
|
+
'span',
|
|
64
57
|
]);
|
|
65
58
|
|
|
66
59
|
/** Map from branch macro name → required parent macro name */
|
|
@@ -102,14 +95,16 @@ export function buildAST(tokens: Token[]): ASTNode[] {
|
|
|
102
95
|
break;
|
|
103
96
|
|
|
104
97
|
case 'link': {
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
98
|
+
const rawArgs = `"${token.display}" "${token.target}"`;
|
|
99
|
+
const macroNode: MacroNode = {
|
|
100
|
+
type: 'macro',
|
|
101
|
+
name: 'link',
|
|
102
|
+
rawArgs,
|
|
103
|
+
children: [],
|
|
109
104
|
};
|
|
110
|
-
if (token.className)
|
|
111
|
-
if (token.id)
|
|
112
|
-
current().push(
|
|
105
|
+
if (token.className) macroNode.className = token.className;
|
|
106
|
+
if (token.id) macroNode.id = token.id;
|
|
107
|
+
current().push(macroNode);
|
|
113
108
|
break;
|
|
114
109
|
}
|
|
115
110
|
|
package/src/markup/render.tsx
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { createContext } from 'preact';
|
|
2
|
-
import { PassageLink } from '../components/PassageLink';
|
|
3
2
|
import { VarDisplay } from '../components/macros/VarDisplay';
|
|
4
3
|
import { WidgetInvocation } from '../components/macros/WidgetInvocation';
|
|
5
4
|
import { getWidget } from '../widgets/widget-registry';
|
|
@@ -136,18 +135,6 @@ function renderSingleNode(
|
|
|
136
135
|
case 'text':
|
|
137
136
|
return node.value;
|
|
138
137
|
|
|
139
|
-
case 'link':
|
|
140
|
-
return (
|
|
141
|
-
<PassageLink
|
|
142
|
-
key={key}
|
|
143
|
-
target={node.target}
|
|
144
|
-
className={node.className}
|
|
145
|
-
id={node.id}
|
|
146
|
-
>
|
|
147
|
-
{node.display}
|
|
148
|
-
</PassageLink>
|
|
149
|
-
);
|
|
150
|
-
|
|
151
138
|
case 'variable':
|
|
152
139
|
return (
|
|
153
140
|
<VarDisplay
|
|
@@ -299,6 +299,49 @@ export async function loadQuickSave(
|
|
|
299
299
|
return loadSave(existingId);
|
|
300
300
|
}
|
|
301
301
|
|
|
302
|
+
// --- Session Persistence (survives F5, cleared on tab close) ---
|
|
303
|
+
|
|
304
|
+
const SESSION_KEY_PREFIX = 'spindle.session.';
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Write a pre-serialized session payload to sessionStorage.
|
|
308
|
+
* Callers are responsible for serializing variables (see persistSession in store.ts).
|
|
309
|
+
*/
|
|
310
|
+
export function saveSession(ifid: string, data: unknown): void {
|
|
311
|
+
try {
|
|
312
|
+
sessionStorage.setItem(
|
|
313
|
+
`${SESSION_KEY_PREFIX}${ifid}`,
|
|
314
|
+
JSON.stringify(data),
|
|
315
|
+
);
|
|
316
|
+
} catch {
|
|
317
|
+
// sessionStorage unavailable or full — silently ignore
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export function loadSession(ifid: string): SavePayload | undefined {
|
|
322
|
+
try {
|
|
323
|
+
const raw = sessionStorage.getItem(`${SESSION_KEY_PREFIX}${ifid}`);
|
|
324
|
+
if (!raw) return undefined;
|
|
325
|
+
const payload: SavePayload = JSON.parse(raw);
|
|
326
|
+
payload.variables = deserialize(payload.variables);
|
|
327
|
+
payload.history = payload.history.map((m) => ({
|
|
328
|
+
...m,
|
|
329
|
+
variables: deserialize(m.variables),
|
|
330
|
+
}));
|
|
331
|
+
return payload;
|
|
332
|
+
} catch {
|
|
333
|
+
return undefined;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export function clearSession(ifid: string): void {
|
|
338
|
+
try {
|
|
339
|
+
sessionStorage.removeItem(`${SESSION_KEY_PREFIX}${ifid}`);
|
|
340
|
+
} catch {
|
|
341
|
+
// ignore
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
302
345
|
// --- Export / Import ---
|
|
303
346
|
|
|
304
347
|
export async function exportSave(
|
package/src/saves/types.ts
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
|
-
import type { HistoryMoment } from '../store';
|
|
2
1
|
import type { PRNGSnapshot } from '../prng';
|
|
3
2
|
|
|
3
|
+
/** History moment as persisted in saves (full variable snapshots). */
|
|
4
|
+
export interface SaveHistoryMoment {
|
|
5
|
+
passage: string;
|
|
6
|
+
variables: Record<string, unknown>;
|
|
7
|
+
timestamp: number;
|
|
8
|
+
prng?: PRNGSnapshot | null;
|
|
9
|
+
}
|
|
10
|
+
|
|
4
11
|
export interface SavePayload {
|
|
5
12
|
passage: string;
|
|
6
13
|
variables: Record<string, unknown>;
|
|
7
|
-
history:
|
|
14
|
+
history: SaveHistoryMoment[];
|
|
8
15
|
historyIndex: number;
|
|
9
16
|
visitCounts?: Record<string, number>;
|
|
10
17
|
renderCounts?: Record<string, number>;
|