@rohal12/spindle 0.52.4 → 0.52.6
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/dist/pkg/headless.js +1502 -1521
- package/dist/pkg/macro-registry.json +255 -0
- package/package.json +1 -1
- package/src/class-registry.ts +6 -182
- package/src/components/macros/Button.tsx +2 -4
- package/src/components/macros/Checkbox.tsx +6 -13
- package/src/components/macros/Computed.tsx +38 -55
- package/src/components/macros/Cycle.tsx +1 -0
- package/src/components/macros/Dialog.tsx +7 -8
- package/src/components/macros/Do.tsx +2 -1
- package/src/components/macros/For.tsx +27 -78
- package/src/components/macros/Goto.tsx +6 -9
- package/src/components/macros/Include.tsx +10 -57
- package/src/components/macros/Listbox.tsx +1 -0
- package/src/components/macros/MacroError.tsx +37 -0
- package/src/components/macros/MacroLink.tsx +9 -24
- package/src/components/macros/Meter.tsx +19 -37
- package/src/components/macros/Numberbox.tsx +5 -32
- package/src/components/macros/PassageDisplay.tsx +37 -47
- package/src/components/macros/Print.tsx +1 -0
- package/src/components/macros/Radiobutton.tsx +8 -38
- package/src/components/macros/Repeat.tsx +3 -3
- package/src/components/macros/Set.tsx +3 -29
- package/src/components/macros/Switch.tsx +36 -37
- package/src/components/macros/Textarea.tsx +2 -28
- package/src/components/macros/Textbox.tsx +2 -29
- package/src/components/macros/Type.tsx +3 -3
- package/src/components/macros/Unset.tsx +17 -40
- package/src/components/macros/Watch.tsx +23 -91
- package/src/components/macros/WidgetInvocation.tsx +9 -71
- package/src/components/macros/detached-body.tsx +2 -2
- package/src/components/macros/input-macro.ts +48 -0
- package/src/components/macros/locals-scope.tsx +60 -0
- package/src/components/macros/macro-args.ts +280 -0
- package/src/components/macros/option-utils.ts +7 -12
- package/src/define-macro.ts +47 -30
- package/src/execute-mutation.ts +17 -97
- package/src/hooks/use-render-options.ts +26 -0
- package/src/markup/render.tsx +2 -4
- package/src/registry.ts +48 -0
- package/src/saves/save-manager.ts +190 -259
- package/src/saves/storage.ts +229 -402
- package/src/store.ts +244 -310
- package/src/structural.ts +388 -0
- package/types/index.d.ts +69 -6
- package/types/tooling.d.ts +33 -1
|
@@ -1,33 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { defineInputMacro } from './input-macro';
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
const { placeholder } = ctx.parseVarArgs(rawArgs);
|
|
8
|
-
|
|
9
|
-
ctx.useAction({
|
|
10
|
-
type: 'numberbox',
|
|
11
|
-
key: `$${ctx.varName}`,
|
|
12
|
-
authorId: ctx.id,
|
|
13
|
-
label: placeholder || ctx.varName!,
|
|
14
|
-
variable: ctx.varName,
|
|
15
|
-
value: ctx.value,
|
|
16
|
-
perform: (v) => ctx.setValue!(v !== undefined ? Number(v) : 0),
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
return (
|
|
20
|
-
<input
|
|
21
|
-
type="number"
|
|
22
|
-
id={ctx.id}
|
|
23
|
-
class={ctx.cls}
|
|
24
|
-
value={ctx.value == null ? '' : String(ctx.value)}
|
|
25
|
-
placeholder={placeholder}
|
|
26
|
-
onInput={(e) => {
|
|
27
|
-
const val = (e.target as HTMLInputElement).value;
|
|
28
|
-
ctx.setValue!(val === '' ? 0 : Number(val));
|
|
29
|
-
}}
|
|
30
|
-
/>
|
|
31
|
-
);
|
|
32
|
-
},
|
|
33
|
-
});
|
|
3
|
+
// An empty input is 0
|
|
4
|
+
defineInputMacro('numberbox', 'number', (v) =>
|
|
5
|
+
v !== undefined ? Number(v) : 0,
|
|
6
|
+
);
|
|
@@ -54,6 +54,20 @@ function setCSSProperties(el: HTMLElement, config: ResolvedTransition): void {
|
|
|
54
54
|
el.style.setProperty('--passage-pause', `${config.pause / 1000}s`);
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Set up `containerEl` for an outgoing transition and snapshot the passage
|
|
59
|
+
* it shows, or return null if there is no container or passage.
|
|
60
|
+
*/
|
|
61
|
+
function snapshotPassage(
|
|
62
|
+
containerEl: HTMLElement | null,
|
|
63
|
+
config: ResolvedTransition,
|
|
64
|
+
): HTMLElement | null {
|
|
65
|
+
if (!containerEl) return null;
|
|
66
|
+
setCSSProperties(containerEl, config);
|
|
67
|
+
const passageEl = containerEl.querySelector('.passage');
|
|
68
|
+
return passageEl ? createSnapshot(passageEl, config.type) : null;
|
|
69
|
+
}
|
|
70
|
+
|
|
57
71
|
/**
|
|
58
72
|
* Remove all snapshot elements and crossfading class from a container.
|
|
59
73
|
*/
|
|
@@ -183,23 +197,19 @@ defineMacro({
|
|
|
183
197
|
return;
|
|
184
198
|
}
|
|
185
199
|
|
|
200
|
+
// The outgoing transitions start from a snapshot of the current passage
|
|
201
|
+
const snapshot =
|
|
202
|
+
config.type === 'fade-through' || config.type === 'crossfade'
|
|
203
|
+
? snapshotPassage(containerEl, config)
|
|
204
|
+
: null;
|
|
205
|
+
if (!containerEl || !snapshot) {
|
|
206
|
+
// Fallback: unknown type or nothing to snapshot, just mount
|
|
207
|
+
setDisplayedPassage(currentPassage);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
|
|
186
211
|
if (config.type === 'fade-through') {
|
|
187
212
|
// idle → outgoing (fade out snapshot) → paused → incoming (mount new, fade in) → idle
|
|
188
|
-
if (!containerEl) {
|
|
189
|
-
setDisplayedPassage(currentPassage);
|
|
190
|
-
return;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
setCSSProperties(containerEl, config);
|
|
194
|
-
|
|
195
|
-
// Take snapshot of current passage
|
|
196
|
-
const passageEl = containerEl.querySelector('.passage');
|
|
197
|
-
if (!passageEl) {
|
|
198
|
-
setDisplayedPassage(currentPassage);
|
|
199
|
-
return;
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
const snapshot = createSnapshot(passageEl, config.type);
|
|
203
213
|
containerEl.appendChild(snapshot);
|
|
204
214
|
|
|
205
215
|
// Hide the current passage during outgoing phase by setting empty key
|
|
@@ -221,42 +231,22 @@ defineMacro({
|
|
|
221
231
|
return;
|
|
222
232
|
}
|
|
223
233
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
if (!containerEl) {
|
|
227
|
-
setDisplayedPassage(currentPassage);
|
|
228
|
-
return;
|
|
229
|
-
}
|
|
234
|
+
// crossfade: idle → crossfading (mount new + fade in, simultaneously
|
|
235
|
+
// fade out snapshot) → idle
|
|
230
236
|
|
|
231
|
-
|
|
237
|
+
// Use CSS grid stacking for crossfade
|
|
238
|
+
containerEl.classList.add('passage-container--crossfading');
|
|
239
|
+
containerEl.appendChild(snapshot);
|
|
232
240
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
if (!passageEl) {
|
|
236
|
-
setDisplayedPassage(currentPassage);
|
|
237
|
-
return;
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
const snapshot = createSnapshot(passageEl, config.type);
|
|
241
|
-
|
|
242
|
-
// Use CSS grid stacking for crossfade
|
|
243
|
-
containerEl.classList.add('passage-container--crossfading');
|
|
244
|
-
containerEl.appendChild(snapshot);
|
|
245
|
-
|
|
246
|
-
// Mount new passage immediately alongside snapshot
|
|
247
|
-
setDisplayedPassage(currentPassage);
|
|
248
|
-
|
|
249
|
-
// After duration, clean up snapshot and remove crossfading class
|
|
250
|
-
const t1 = setTimeout(() => {
|
|
251
|
-
cleanupSnapshots(containerEl);
|
|
252
|
-
}, config.duration);
|
|
241
|
+
// Mount new passage immediately alongside snapshot
|
|
242
|
+
setDisplayedPassage(currentPassage);
|
|
253
243
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
244
|
+
// After duration, clean up snapshot and remove crossfading class
|
|
245
|
+
const t1 = setTimeout(() => {
|
|
246
|
+
cleanupSnapshots(containerEl);
|
|
247
|
+
}, config.duration);
|
|
257
248
|
|
|
258
|
-
|
|
259
|
-
setDisplayedPassage(currentPassage);
|
|
249
|
+
timeoutsRef.current = [t1];
|
|
260
250
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
261
251
|
}, [navigationId]);
|
|
262
252
|
|
|
@@ -1,46 +1,16 @@
|
|
|
1
1
|
import { defineMacro } from '../../define-macro';
|
|
2
|
-
import {
|
|
3
|
-
isWhitespace,
|
|
4
|
-
readQuoted,
|
|
5
|
-
readWholeQuoted,
|
|
6
|
-
stripLooseQuotes,
|
|
7
|
-
} from './arg-utils';
|
|
8
|
-
|
|
9
|
-
export function parseRadioArgs(rawArgs: string): {
|
|
10
|
-
value: string;
|
|
11
|
-
label: string;
|
|
12
|
-
} {
|
|
13
|
-
// $var "value" label: a quoted value accepts \" \' and \\ escapes and
|
|
14
|
-
// may contain the other quote kind.
|
|
15
|
-
const head = rawArgs.match(/^\s*["']?\$?[\w.]+["']?\s+/);
|
|
16
|
-
if (head) {
|
|
17
|
-
const rest = rawArgs.slice(head[0].length).trim();
|
|
18
|
-
const value = readQuoted(rest, 0);
|
|
19
|
-
if (
|
|
20
|
-
value &&
|
|
21
|
-
(value.end === rest.length || isWhitespace(rest[value.end]!))
|
|
22
|
-
) {
|
|
23
|
-
const labelRaw = rest.slice(value.end).trim();
|
|
24
|
-
const label = readWholeQuoted(labelRaw) ?? stripLooseQuotes(labelRaw);
|
|
25
|
-
return { value: value.value, label };
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
const match = rawArgs.match(
|
|
30
|
-
/^\s*["']?\$?[\w.]+["']?\s+["'](.+?)["']\s+["']?(.+?)["']?\s*$/s,
|
|
31
|
-
);
|
|
32
|
-
if (!match) {
|
|
33
|
-
const parts = rawArgs.trim().split(/\s+/).slice(1);
|
|
34
|
-
return { value: parts[0] ?? '', label: parts.slice(1).join(' ') };
|
|
35
|
-
}
|
|
36
|
-
return { value: match[1]!, label: match[2]! };
|
|
37
|
-
}
|
|
38
2
|
|
|
39
3
|
defineMacro({
|
|
40
4
|
name: 'radiobutton',
|
|
41
5
|
storeVar: true,
|
|
42
|
-
|
|
43
|
-
|
|
6
|
+
parameters: [
|
|
7
|
+
{ name: 'variable', type: 'variable', required: true },
|
|
8
|
+
{ name: 'value', type: 'text', required: true },
|
|
9
|
+
{ name: 'label', type: 'text' },
|
|
10
|
+
],
|
|
11
|
+
render(_props, ctx) {
|
|
12
|
+
const radioValue = ctx.args.value ?? '';
|
|
13
|
+
const label = ctx.args.label ?? '';
|
|
44
14
|
|
|
45
15
|
ctx.useAction({
|
|
46
16
|
type: 'radiobutton',
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { createContext } from 'preact';
|
|
2
|
-
import { parseDelay } from '../../utils/parse-delay';
|
|
3
2
|
import { defineMacro } from '../../define-macro';
|
|
4
3
|
|
|
5
4
|
export const RepeatContext = createContext<{ stop: () => void }>({
|
|
@@ -10,10 +9,11 @@ defineMacro({
|
|
|
10
9
|
name: 'repeat',
|
|
11
10
|
block: true,
|
|
12
11
|
interpolate: true,
|
|
13
|
-
|
|
12
|
+
parameters: [{ name: 'delay', type: 'delay', required: true }],
|
|
13
|
+
render({ children = [] }, ctx) {
|
|
14
14
|
const { useState, useEffect, useCallback } = ctx.hooks;
|
|
15
15
|
|
|
16
|
-
const delay =
|
|
16
|
+
const delay = ctx.args.delay ?? 0;
|
|
17
17
|
const [count, setCount] = useState(0);
|
|
18
18
|
const [stopped, setStopped] = useState(false);
|
|
19
19
|
|
|
@@ -1,36 +1,10 @@
|
|
|
1
1
|
import { defineMacro } from '../../define-macro';
|
|
2
|
-
import {
|
|
2
|
+
import { useRunOnce } from './MacroError';
|
|
3
3
|
|
|
4
4
|
defineMacro({
|
|
5
5
|
name: 'set',
|
|
6
|
+
parameters: [{ name: 'code', type: 'expression', required: true }],
|
|
6
7
|
render({ rawArgs }, ctx) {
|
|
7
|
-
|
|
8
|
-
// Boxed: anything can be thrown, including null and other falsy values
|
|
9
|
-
const failure = ctx.hooks.useRef<{ error: unknown } | null>(null);
|
|
10
|
-
|
|
11
|
-
if (!ran.current) {
|
|
12
|
-
ran.current = true;
|
|
13
|
-
|
|
14
|
-
try {
|
|
15
|
-
ctx.mutate(rawArgs);
|
|
16
|
-
} catch (err) {
|
|
17
|
-
failure.current = { error: err };
|
|
18
|
-
console.error(
|
|
19
|
-
`spindle: Error in {set ${rawArgs}}${ctx.sourceLocation()}:`,
|
|
20
|
-
err,
|
|
21
|
-
);
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
if (failure.current) {
|
|
26
|
-
return (
|
|
27
|
-
<MacroError
|
|
28
|
-
macro="set"
|
|
29
|
-
error={failure.current.error}
|
|
30
|
-
/>
|
|
31
|
-
);
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
return null;
|
|
8
|
+
return useRunOnce('set', rawArgs, () => ctx.mutate(rawArgs));
|
|
35
9
|
},
|
|
36
10
|
});
|
|
@@ -1,9 +1,32 @@
|
|
|
1
1
|
import { defineMacro } from '../../define-macro';
|
|
2
2
|
import { MacroError } from './MacroError';
|
|
3
|
+
import type { Branch } from '../../markup/ast';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The first {case} branch whose value equals `value`, else the {default}
|
|
7
|
+
* branch, else null. Throws what a case expression throws.
|
|
8
|
+
*/
|
|
9
|
+
function selectBranch(
|
|
10
|
+
value: unknown,
|
|
11
|
+
branches: Branch[],
|
|
12
|
+
evaluate: (expr: string) => unknown,
|
|
13
|
+
): Branch | null {
|
|
14
|
+
let defaultBranch: Branch | null = null;
|
|
15
|
+
for (let i = 1; i < branches.length; i++) {
|
|
16
|
+
const branch = branches[i]!;
|
|
17
|
+
if (branch.rawArgs === '') {
|
|
18
|
+
defaultBranch = branch;
|
|
19
|
+
} else if (value === evaluate(branch.rawArgs)) {
|
|
20
|
+
return branch;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return defaultBranch;
|
|
24
|
+
}
|
|
3
25
|
|
|
4
26
|
defineMacro({
|
|
5
27
|
name: 'switch',
|
|
6
28
|
subMacros: ['case', 'default'],
|
|
29
|
+
parameters: [{ name: 'expression', type: 'expression', required: true }],
|
|
7
30
|
merged: true,
|
|
8
31
|
render({ rawArgs, branches = [] }, ctx) {
|
|
9
32
|
let switchValue: unknown;
|
|
@@ -18,46 +41,22 @@ defineMacro({
|
|
|
18
41
|
);
|
|
19
42
|
}
|
|
20
43
|
|
|
21
|
-
let
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
if (switchValue === caseValue) {
|
|
32
|
-
return <>{ctx.renderNodes(branch.children)}</>;
|
|
33
|
-
}
|
|
34
|
-
} catch (err) {
|
|
35
|
-
return (
|
|
36
|
-
<MacroError
|
|
37
|
-
macro="case"
|
|
38
|
-
error={err}
|
|
39
|
-
/>
|
|
40
|
-
);
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
if (defaultBranch) {
|
|
45
|
-
return <>{ctx.renderNodes(defaultBranch.children)}</>;
|
|
44
|
+
let branch: Branch | null;
|
|
45
|
+
try {
|
|
46
|
+
branch = selectBranch(switchValue, branches, ctx.evaluate!);
|
|
47
|
+
} catch (err) {
|
|
48
|
+
return (
|
|
49
|
+
<MacroError
|
|
50
|
+
macro="case"
|
|
51
|
+
error={err}
|
|
52
|
+
/>
|
|
53
|
+
);
|
|
46
54
|
}
|
|
47
55
|
|
|
48
|
-
return
|
|
56
|
+
return branch && <>{ctx.renderNodes(branch.children)}</>;
|
|
49
57
|
},
|
|
50
58
|
text({ rawArgs, branches = [] }, ctx) {
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
for (let i = 1; i < branches.length; i++) {
|
|
54
|
-
const branch = branches[i]!;
|
|
55
|
-
if (branch.rawArgs === '') {
|
|
56
|
-
defaultBranch = branch;
|
|
57
|
-
} else if (switchValue === ctx.evaluate(branch.rawArgs)) {
|
|
58
|
-
return ctx.renderText(branch.children);
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
return defaultBranch ? ctx.renderText(defaultBranch.children) : '';
|
|
59
|
+
const branch = selectBranch(ctx.evaluate(rawArgs), branches, ctx.evaluate);
|
|
60
|
+
return branch ? ctx.renderText(branch.children) : '';
|
|
62
61
|
},
|
|
63
62
|
});
|
|
@@ -1,29 +1,3 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { defineInputMacro } from './input-macro';
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
name: 'textarea',
|
|
5
|
-
storeVar: true,
|
|
6
|
-
render({ rawArgs }, ctx) {
|
|
7
|
-
const { placeholder } = ctx.parseVarArgs(rawArgs);
|
|
8
|
-
|
|
9
|
-
ctx.useAction({
|
|
10
|
-
type: 'textarea',
|
|
11
|
-
key: `$${ctx.varName}`,
|
|
12
|
-
authorId: ctx.id,
|
|
13
|
-
label: placeholder || ctx.varName!,
|
|
14
|
-
variable: ctx.varName,
|
|
15
|
-
value: ctx.value,
|
|
16
|
-
perform: (v) => ctx.setValue!(v !== undefined ? String(v) : ''),
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
return (
|
|
20
|
-
<textarea
|
|
21
|
-
id={ctx.id}
|
|
22
|
-
class={ctx.cls}
|
|
23
|
-
value={ctx.value == null ? '' : String(ctx.value)}
|
|
24
|
-
placeholder={placeholder}
|
|
25
|
-
onInput={(e) => ctx.setValue!((e.target as HTMLTextAreaElement).value)}
|
|
26
|
-
/>
|
|
27
|
-
);
|
|
28
|
-
},
|
|
29
|
-
});
|
|
3
|
+
defineInputMacro('textarea');
|
|
@@ -1,30 +1,3 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { defineInputMacro } from './input-macro';
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
name: 'textbox',
|
|
5
|
-
storeVar: true,
|
|
6
|
-
render({ rawArgs }, ctx) {
|
|
7
|
-
const { placeholder } = ctx.parseVarArgs(rawArgs);
|
|
8
|
-
|
|
9
|
-
ctx.useAction({
|
|
10
|
-
type: 'textbox',
|
|
11
|
-
key: `$${ctx.varName}`,
|
|
12
|
-
authorId: ctx.id,
|
|
13
|
-
label: placeholder || ctx.varName!,
|
|
14
|
-
variable: ctx.varName,
|
|
15
|
-
value: ctx.value,
|
|
16
|
-
perform: (v) => ctx.setValue!(v !== undefined ? String(v) : ''),
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
return (
|
|
20
|
-
<input
|
|
21
|
-
type="text"
|
|
22
|
-
id={ctx.id}
|
|
23
|
-
class={ctx.cls}
|
|
24
|
-
value={ctx.value == null ? '' : String(ctx.value)}
|
|
25
|
-
placeholder={placeholder}
|
|
26
|
-
onInput={(e) => ctx.setValue!((e.target as HTMLInputElement).value)}
|
|
27
|
-
/>
|
|
28
|
-
);
|
|
29
|
-
},
|
|
30
|
-
});
|
|
3
|
+
defineInputMacro('textbox', 'text');
|
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
import { parseDelay } from '../../utils/parse-delay';
|
|
2
1
|
import { defineMacro } from '../../define-macro';
|
|
3
2
|
|
|
4
3
|
defineMacro({
|
|
5
4
|
name: 'type',
|
|
6
5
|
block: true,
|
|
7
6
|
interpolate: true,
|
|
8
|
-
|
|
7
|
+
parameters: [{ name: 'delay', type: 'delay', required: true }],
|
|
8
|
+
render({ children = [] }, ctx) {
|
|
9
9
|
const { useState, useEffect, useRef } = ctx.hooks;
|
|
10
|
-
const speed =
|
|
10
|
+
const speed = ctx.args.delay ?? 0;
|
|
11
11
|
const containerRef = useRef<HTMLSpanElement>(null);
|
|
12
12
|
const [totalChars, setTotalChars] = useState(0);
|
|
13
13
|
const [visibleChars, setVisibleChars] = useState(0);
|
|
@@ -1,52 +1,29 @@
|
|
|
1
1
|
import { useStoryStore } from '../../store';
|
|
2
2
|
import { defineMacro } from '../../define-macro';
|
|
3
|
-
import {
|
|
3
|
+
import { useRunOnce } from './MacroError';
|
|
4
4
|
|
|
5
5
|
defineMacro({
|
|
6
6
|
name: 'unset',
|
|
7
|
+
parameters: [{ name: 'variable', type: 'variable', required: true }],
|
|
7
8
|
render({ rawArgs }, ctx) {
|
|
8
|
-
|
|
9
|
-
// Boxed: anything can be thrown, including null and other falsy values
|
|
10
|
-
const failure = ctx.hooks.useRef<{ error: unknown } | null>(null);
|
|
11
|
-
|
|
12
|
-
if (!ran.current) {
|
|
13
|
-
ran.current = true;
|
|
9
|
+
return useRunOnce('unset', rawArgs, () => {
|
|
14
10
|
const state = useStoryStore.getState();
|
|
15
|
-
const name =
|
|
11
|
+
const name = ctx.args.variable ?? '';
|
|
16
12
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
`{unset} expects a variable ($name, _name, %name, or @name), got "${name}"`,
|
|
30
|
-
);
|
|
31
|
-
}
|
|
32
|
-
} catch (err) {
|
|
33
|
-
failure.current = { error: err };
|
|
34
|
-
console.error(
|
|
35
|
-
`spindle: Error in {unset ${rawArgs}}${ctx.sourceLocation()}:`,
|
|
36
|
-
err,
|
|
13
|
+
if (name.startsWith('$')) {
|
|
14
|
+
state.deleteVariable(name.slice(1));
|
|
15
|
+
} else if (name.startsWith('_')) {
|
|
16
|
+
state.deleteTemporary(name.slice(1));
|
|
17
|
+
} else if (name.startsWith('%')) {
|
|
18
|
+
state.deleteTransient(name.slice(1));
|
|
19
|
+
} else if (name.startsWith('@')) {
|
|
20
|
+
// Throws outside a locals scope ({for}, widget, {link}, {button})
|
|
21
|
+
ctx.update(name.slice(1), undefined);
|
|
22
|
+
} else {
|
|
23
|
+
throw new Error(
|
|
24
|
+
`{unset} expects a variable ($name, _name, %name, or @name), got "${name}"`,
|
|
37
25
|
);
|
|
38
26
|
}
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
if (failure.current) {
|
|
42
|
-
return (
|
|
43
|
-
<MacroError
|
|
44
|
-
macro="unset"
|
|
45
|
-
error={failure.current.error}
|
|
46
|
-
/>
|
|
47
|
-
);
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
return null;
|
|
27
|
+
});
|
|
51
28
|
},
|
|
52
29
|
});
|
|
@@ -1,99 +1,30 @@
|
|
|
1
1
|
import { defineMacro } from '../../define-macro';
|
|
2
2
|
import { addMacroTrigger, removeTrigger } from '../../triggers';
|
|
3
|
-
import type { WatchOptions } from '../../triggers';
|
|
4
|
-
import { isWhitespace, readQuoted, readWholeQuoted } from './arg-utils';
|
|
5
|
-
|
|
6
|
-
const WORD_RE = /\w+/y;
|
|
7
|
-
const DIGITS_RE = /\d+/y;
|
|
8
|
-
|
|
9
|
-
/** Match the sticky regex `re` at `pos`, returning the matched text. */
|
|
10
|
-
function matchAt(re: RegExp, src: string, pos: number): string | null {
|
|
11
|
-
re.lastIndex = pos;
|
|
12
|
-
return re.exec(src)?.[0] ?? null;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* Parse `{watch "condition" keyword value …}`: a quoted condition followed
|
|
17
|
-
* by keyword options (`goto "X"`, `dialog "X"`, `run "X"`, `name "X"`,
|
|
18
|
-
* `priority N`, `once`). Quoted strings accept `\"`, `\'` and `\\` escapes.
|
|
19
|
-
* Returns `null` when the arguments do not start with a quoted condition.
|
|
20
|
-
*/
|
|
21
|
-
export function parseWatchArgs(
|
|
22
|
-
rawArgs: string,
|
|
23
|
-
): { condition: string; options: WatchOptions } | null {
|
|
24
|
-
const raw = rawArgs.trim();
|
|
25
|
-
const cond = readQuoted(raw, 0);
|
|
26
|
-
if (!cond) return null;
|
|
27
|
-
|
|
28
|
-
const options: WatchOptions = {};
|
|
29
|
-
let i = cond.end;
|
|
30
|
-
while (i < raw.length) {
|
|
31
|
-
const key = matchAt(WORD_RE, raw, i);
|
|
32
|
-
if (!key) {
|
|
33
|
-
// Skip whitespace and stray characters, or a stray quoted string.
|
|
34
|
-
i = readQuoted(raw, i)?.end ?? i + 1;
|
|
35
|
-
continue;
|
|
36
|
-
}
|
|
37
|
-
i += key.length;
|
|
38
|
-
|
|
39
|
-
// A value is a quoted string or a digit run, after whitespace.
|
|
40
|
-
let val: string | undefined;
|
|
41
|
-
let j = i;
|
|
42
|
-
while (j < raw.length && isWhitespace(raw[j]!)) j++;
|
|
43
|
-
if (j > i) {
|
|
44
|
-
const quoted = readQuoted(raw, j);
|
|
45
|
-
const digits = quoted ? null : matchAt(DIGITS_RE, raw, j);
|
|
46
|
-
if (quoted) {
|
|
47
|
-
val = quoted.value;
|
|
48
|
-
i = quoted.end;
|
|
49
|
-
} else if (digits) {
|
|
50
|
-
val = digits;
|
|
51
|
-
i = j + digits.length;
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
switch (key) {
|
|
56
|
-
case 'goto':
|
|
57
|
-
options.goto = val;
|
|
58
|
-
break;
|
|
59
|
-
case 'dialog':
|
|
60
|
-
options.dialog = val;
|
|
61
|
-
break;
|
|
62
|
-
case 'run':
|
|
63
|
-
options.run = val;
|
|
64
|
-
break;
|
|
65
|
-
case 'name':
|
|
66
|
-
options.name = val;
|
|
67
|
-
break;
|
|
68
|
-
case 'priority':
|
|
69
|
-
options.priority = val ? Number(val) : 0;
|
|
70
|
-
break;
|
|
71
|
-
case 'once':
|
|
72
|
-
options.once = true;
|
|
73
|
-
break;
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
return { condition: cond.value, options };
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* Parse the watcher name of `{unwatch "name"}`: a quoted string, with the
|
|
82
|
-
* same escapes as `{watch}`, or a bare name.
|
|
83
|
-
*/
|
|
84
|
-
export function parseUnwatchName(rawArgs: string): string {
|
|
85
|
-
const raw = rawArgs.trim();
|
|
86
|
-
return readWholeQuoted(raw) ?? raw.replace(/^['"]|['"]$/g, '');
|
|
87
|
-
}
|
|
88
3
|
|
|
89
4
|
defineMacro({
|
|
90
5
|
name: 'watch',
|
|
91
|
-
|
|
6
|
+
// {watch "condition" goto "X" dialog "X" run "X" name "X" priority N once}
|
|
7
|
+
parameters: [
|
|
8
|
+
{ name: 'condition', type: 'string', required: true },
|
|
9
|
+
{
|
|
10
|
+
name: 'options',
|
|
11
|
+
type: 'options',
|
|
12
|
+
parameters: [
|
|
13
|
+
{ name: 'goto', type: 'string' },
|
|
14
|
+
{ name: 'dialog', type: 'string' },
|
|
15
|
+
{ name: 'run', type: 'string' },
|
|
16
|
+
{ name: 'name', type: 'string' },
|
|
17
|
+
{ name: 'priority', type: 'number' },
|
|
18
|
+
{ name: 'once', type: 'flag' },
|
|
19
|
+
],
|
|
20
|
+
},
|
|
21
|
+
],
|
|
22
|
+
render(_props, ctx) {
|
|
92
23
|
const { hooks } = ctx;
|
|
93
24
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
25
|
+
// Without a quoted condition there is nothing to watch.
|
|
26
|
+
const { condition, options } = ctx.args;
|
|
27
|
+
if (condition === undefined) return null;
|
|
97
28
|
|
|
98
29
|
// Register during render (like {set}) — triggers survive navigation
|
|
99
30
|
// and are cleaned up via resetTriggers() on restart or {unwatch}.
|
|
@@ -110,8 +41,9 @@ defineMacro({
|
|
|
110
41
|
|
|
111
42
|
defineMacro({
|
|
112
43
|
name: 'unwatch',
|
|
113
|
-
|
|
114
|
-
|
|
44
|
+
parameters: [{ name: 'name', type: 'text', required: true }],
|
|
45
|
+
render(_props, ctx) {
|
|
46
|
+
const name = ctx.args.name ?? '';
|
|
115
47
|
|
|
116
48
|
const ran = ctx.hooks.useRef(false);
|
|
117
49
|
if (!ran.current) {
|