@rohal12/spindle 0.52.5 → 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 +651 -544
- package/dist/pkg/macro-registry.json +255 -0
- package/package.json +1 -1
- 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/hooks/use-render-options.ts +26 -0
- package/src/markup/render.tsx +2 -4
- package/src/registry.ts +48 -0
- package/types/index.d.ts +69 -6
- package/types/tooling.d.ts +33 -1
|
@@ -1,47 +1,29 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
useState,
|
|
4
|
-
useCallback,
|
|
5
|
-
useRef,
|
|
6
|
-
useMemo,
|
|
7
|
-
} from 'preact/hooks';
|
|
8
|
-
import {
|
|
9
|
-
LocalsValuesContext,
|
|
10
|
-
LocalsUpdateContext,
|
|
11
|
-
NobrContext,
|
|
12
|
-
InlineContext,
|
|
13
|
-
RawTextContext,
|
|
14
|
-
renderNodes,
|
|
15
|
-
} from '../../markup/render';
|
|
1
|
+
import { useContext, useMemo } from 'preact/hooks';
|
|
2
|
+
import { LocalsValuesContext } from '../../markup/render';
|
|
16
3
|
import { defineMacro } from '../../define-macro';
|
|
17
4
|
import { MacroError } from './MacroError';
|
|
18
5
|
import { stableKey } from '../../utils/stable-key';
|
|
19
6
|
import type { ASTNode } from '../../markup/ast';
|
|
20
|
-
import {
|
|
21
|
-
|
|
22
|
-
createNamespace,
|
|
23
|
-
withEntry,
|
|
24
|
-
} from '../../utils/namespace';
|
|
7
|
+
import { checkVariableName } from '../../utils/namespace';
|
|
8
|
+
import { LocalsScope } from './locals-scope';
|
|
25
9
|
|
|
26
10
|
/**
|
|
27
|
-
*
|
|
11
|
+
* Check the arguments of "@item, @i of $list" or "@item of $list".
|
|
28
12
|
*/
|
|
29
|
-
function
|
|
13
|
+
function loopArgs(
|
|
14
|
+
rawArgs: string,
|
|
15
|
+
args: { variables?: string[]; of: boolean; list?: string },
|
|
16
|
+
): {
|
|
30
17
|
itemVar: string;
|
|
31
18
|
indexVar: string | null;
|
|
32
19
|
listExpr: string;
|
|
33
20
|
} {
|
|
34
|
-
|
|
35
|
-
if (ofIdx === -1) {
|
|
21
|
+
if (!args.of) {
|
|
36
22
|
throw new Error(`{for} requires "of" keyword: {for ${rawArgs}}`);
|
|
37
23
|
}
|
|
38
24
|
|
|
39
|
-
const
|
|
40
|
-
const listExpr =
|
|
41
|
-
|
|
42
|
-
const vars = varsPart.split(',').map((v) => v.trim());
|
|
43
|
-
const itemVar = vars[0]!;
|
|
44
|
-
const indexVar = vars.length > 1 ? vars[1]! : null;
|
|
25
|
+
const [itemVar = '', indexVar = null] = args.variables ?? [];
|
|
26
|
+
const listExpr = args.list ?? '';
|
|
45
27
|
|
|
46
28
|
if (!itemVar.startsWith('@')) {
|
|
47
29
|
throw new Error(`{for} loop variable must use @ prefix: got "${itemVar}"`);
|
|
@@ -77,10 +59,6 @@ function ForIteration({
|
|
|
77
59
|
indexValue: number;
|
|
78
60
|
children: ASTNode[];
|
|
79
61
|
}) {
|
|
80
|
-
const [localMutations, setLocalMutations] = useState<Record<string, unknown>>(
|
|
81
|
-
() => ({}),
|
|
82
|
-
);
|
|
83
|
-
|
|
84
62
|
const ownKeys = useMemo(
|
|
85
63
|
() => ({
|
|
86
64
|
[itemVar]: itemValue,
|
|
@@ -89,35 +67,12 @@ function ForIteration({
|
|
|
89
67
|
[itemVar, itemValue, indexVar, indexValue],
|
|
90
68
|
);
|
|
91
69
|
|
|
92
|
-
const localState = useMemo(
|
|
93
|
-
() => createNamespace(parentValues, ownKeys, localMutations),
|
|
94
|
-
[parentValues, ownKeys, localMutations],
|
|
95
|
-
);
|
|
96
|
-
|
|
97
|
-
const valuesRef = useRef(localState);
|
|
98
|
-
valuesRef.current = localState;
|
|
99
|
-
|
|
100
|
-
const getValues = useCallback(() => valuesRef.current, []);
|
|
101
|
-
const update = useCallback((key: string, value: unknown) => {
|
|
102
|
-
// Apply synchronously so later macros in the same render pass (e.g. a
|
|
103
|
-
// second {set}) read the new value via getValues(); the state update
|
|
104
|
-
// then re-renders consumers of LocalsValuesContext.
|
|
105
|
-
checkVariableName(key, `@${key}`);
|
|
106
|
-
valuesRef.current = withEntry(valuesRef.current, key, value);
|
|
107
|
-
setLocalMutations((prev) => ({ ...prev, [key]: value }));
|
|
108
|
-
}, []);
|
|
109
|
-
const updater = useMemo(() => ({ update, getValues }), [update, getValues]);
|
|
110
|
-
|
|
111
|
-
const nobr = useContext(NobrContext);
|
|
112
|
-
const inline = useContext(InlineContext);
|
|
113
|
-
const raw = useContext(RawTextContext);
|
|
114
|
-
|
|
115
70
|
return (
|
|
116
|
-
<
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
71
|
+
<LocalsScope
|
|
72
|
+
body={children}
|
|
73
|
+
parentValues={parentValues}
|
|
74
|
+
ownKeys={ownKeys}
|
|
75
|
+
/>
|
|
121
76
|
);
|
|
122
77
|
}
|
|
123
78
|
|
|
@@ -126,26 +81,19 @@ defineMacro({
|
|
|
126
81
|
block: true,
|
|
127
82
|
interpolate: true,
|
|
128
83
|
merged: true,
|
|
84
|
+
parameters: [
|
|
85
|
+
{ name: 'variables', type: 'names', required: true },
|
|
86
|
+
{ name: 'of', type: 'separator' },
|
|
87
|
+
{ name: 'list', type: 'expression', required: true },
|
|
88
|
+
],
|
|
129
89
|
render({ rawArgs, children = [] }, ctx) {
|
|
130
90
|
const parentValues = useContext(LocalsValuesContext);
|
|
131
91
|
|
|
132
|
-
let
|
|
133
|
-
try {
|
|
134
|
-
parsed = parseForArgs(rawArgs);
|
|
135
|
-
} catch (err) {
|
|
136
|
-
return (
|
|
137
|
-
<MacroError
|
|
138
|
-
macro="for"
|
|
139
|
-
error={err}
|
|
140
|
-
/>
|
|
141
|
-
);
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
const { itemVar, indexVar, listExpr } = parsed;
|
|
145
|
-
|
|
92
|
+
let loop: ReturnType<typeof loopArgs>;
|
|
146
93
|
let list: unknown[];
|
|
147
94
|
try {
|
|
148
|
-
|
|
95
|
+
loop = loopArgs(rawArgs, ctx.args);
|
|
96
|
+
const result = ctx.evaluate!(loop.listExpr);
|
|
149
97
|
if (!Array.isArray(result)) {
|
|
150
98
|
return (
|
|
151
99
|
<span class="error">
|
|
@@ -163,6 +111,7 @@ defineMacro({
|
|
|
163
111
|
);
|
|
164
112
|
}
|
|
165
113
|
|
|
114
|
+
const { itemVar, indexVar } = loop;
|
|
166
115
|
const content = list.map((item, i) => (
|
|
167
116
|
<ForIteration
|
|
168
117
|
key={`${i}-${stableKey(item)}`}
|
|
@@ -178,7 +127,7 @@ defineMacro({
|
|
|
178
127
|
return ctx.wrap(content);
|
|
179
128
|
},
|
|
180
129
|
text({ rawArgs, children = [] }, ctx) {
|
|
181
|
-
const { itemVar, indexVar, listExpr } =
|
|
130
|
+
const { itemVar, indexVar, listExpr } = loopArgs(rawArgs, ctx.args);
|
|
182
131
|
const list = ctx.evaluate(listExpr);
|
|
183
132
|
if (!Array.isArray(list)) {
|
|
184
133
|
throw new Error('expression did not evaluate to an array');
|
|
@@ -1,19 +1,16 @@
|
|
|
1
1
|
import { useStoryStore } from '../../store';
|
|
2
2
|
import { defineMacro } from '../../define-macro';
|
|
3
|
+
import { evaluatePassageName } from './macro-args';
|
|
3
4
|
|
|
4
5
|
defineMacro({
|
|
5
6
|
name: 'goto',
|
|
6
7
|
merged: true,
|
|
7
|
-
|
|
8
|
+
parameters: [{ name: 'passage', type: 'expression', required: true }],
|
|
9
|
+
render(_props, ctx) {
|
|
8
10
|
ctx.hooks.useLayoutEffect(() => {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
passageName = String(result);
|
|
13
|
-
} catch {
|
|
14
|
-
passageName = rawArgs.replace(/^["']|["']$/g, '');
|
|
15
|
-
}
|
|
16
|
-
useStoryStore.getState().navigate(passageName);
|
|
11
|
+
useStoryStore
|
|
12
|
+
.getState()
|
|
13
|
+
.navigate(evaluatePassageName(ctx.args.passage, ctx.evaluate!));
|
|
17
14
|
}, []);
|
|
18
15
|
|
|
19
16
|
return null;
|
|
@@ -3,70 +3,23 @@ import { tokenize } from '../../markup/tokenizer';
|
|
|
3
3
|
import { buildAST } from '../../markup/ast';
|
|
4
4
|
import { NobrContext } from '../../markup/render';
|
|
5
5
|
import { defineMacro } from '../../define-macro';
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
const FLAG = 'inline';
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Split a standalone `inline` flag off the start or end of the include
|
|
12
|
-
* arguments. The flag only counts outside quotes, brackets, parentheses and
|
|
13
|
-
* braces, separated by whitespace from the target expression, so passage
|
|
14
|
-
* names and expressions containing the word stay intact (#201).
|
|
15
|
-
*/
|
|
16
|
-
export function parseIncludeArgs(rawArgs: string): {
|
|
17
|
-
nameExpr: string;
|
|
18
|
-
inline: boolean;
|
|
19
|
-
} {
|
|
20
|
-
const trimmed = rawArgs.trim();
|
|
21
|
-
|
|
22
|
-
// First and last whitespace runs at depth 0, as [start, end) offsets.
|
|
23
|
-
const spaces = topLevelIndices(trimmed, isWhitespace);
|
|
24
|
-
let first: [number, number] | null = null;
|
|
25
|
-
let last: [number, number] | null = null;
|
|
26
|
-
if (spaces.length > 0) {
|
|
27
|
-
let s = 0;
|
|
28
|
-
while (s + 1 < spaces.length && spaces[s + 1] === spaces[s]! + 1) s++;
|
|
29
|
-
first = [spaces[0]!, spaces[s]! + 1];
|
|
30
|
-
let e = spaces.length - 1;
|
|
31
|
-
while (e > 0 && spaces[e - 1] === spaces[e]! - 1) e--;
|
|
32
|
-
last = [spaces[e]!, spaces[spaces.length - 1]! + 1];
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
// Next to a binary operator the word is an operand (`"a" + inline`);
|
|
36
|
-
// the closing `/` of a regex literal is not one.
|
|
37
|
-
if (
|
|
38
|
-
last &&
|
|
39
|
-
last[1] === trimmed.length - FLAG.length &&
|
|
40
|
-
trimmed.endsWith(FLAG)
|
|
41
|
-
) {
|
|
42
|
-
const rest = trimmed.slice(0, last[0]);
|
|
43
|
-
if (!endsWithOperator(rest)) return { nameExpr: rest, inline: true };
|
|
44
|
-
}
|
|
45
|
-
if (first && first[0] === FLAG.length && trimmed.startsWith(FLAG)) {
|
|
46
|
-
const rest = trimmed.slice(first[1]);
|
|
47
|
-
// `inline %name` reads a transient variable; `inline % 2` is modulo.
|
|
48
|
-
if (!/^(?:[-+*/&|^=<>?:,.]|%(?![A-Za-z_]))/.test(rest))
|
|
49
|
-
return { nameExpr: rest, inline: true };
|
|
50
|
-
}
|
|
51
|
-
return { nameExpr: trimmed, inline: false };
|
|
52
|
-
}
|
|
6
|
+
import { evaluatePassageName } from './macro-args';
|
|
53
7
|
|
|
54
8
|
defineMacro({
|
|
55
9
|
name: 'include',
|
|
56
10
|
interpolate: true,
|
|
57
11
|
merged: true,
|
|
58
|
-
|
|
12
|
+
// A standalone `inline` flag only counts outside quotes and brackets, so
|
|
13
|
+
// passage names and expressions containing the word stay intact (#201).
|
|
14
|
+
parameters: [
|
|
15
|
+
{ name: 'inline', type: 'flag' },
|
|
16
|
+
{ name: 'passage', type: 'expression', required: true },
|
|
17
|
+
],
|
|
18
|
+
render(_props, ctx) {
|
|
59
19
|
const storyData = useStoryStore((s) => s.storyData);
|
|
60
20
|
|
|
61
|
-
const {
|
|
62
|
-
|
|
63
|
-
let passageName: string;
|
|
64
|
-
try {
|
|
65
|
-
const result = ctx.evaluate!(nameExpr);
|
|
66
|
-
passageName = String(result);
|
|
67
|
-
} catch {
|
|
68
|
-
passageName = nameExpr.replace(/^["']|["']$/g, '');
|
|
69
|
-
}
|
|
21
|
+
const { inline } = ctx.args;
|
|
22
|
+
const passageName = evaluatePassageName(ctx.args.passage, ctx.evaluate!);
|
|
70
23
|
|
|
71
24
|
const passage = storyData?.passages.get(passageName);
|
|
72
25
|
// Parse once per included passage: the renderer keys children by AST
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { useRef } from 'preact/hooks';
|
|
1
2
|
import { currentSourceLocation } from '../../utils/source-location';
|
|
2
3
|
import { errorMessage } from '../../utils/error-message';
|
|
3
4
|
|
|
@@ -17,3 +18,39 @@ export function MacroError({
|
|
|
17
18
|
</span>
|
|
18
19
|
);
|
|
19
20
|
}
|
|
21
|
+
|
|
22
|
+
/** Log an error in the macro `{label}` (its name, or name and arguments). */
|
|
23
|
+
export function logMacroError(label: string, error: unknown): void {
|
|
24
|
+
console.error(
|
|
25
|
+
`spindle: Error in {${label}}${currentSourceLocation()}:`,
|
|
26
|
+
error,
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Run `effect` once, during the macro's first render. If it throws, the
|
|
32
|
+
* error is logged under `{macro rawArgs}` and shown in place of the macro
|
|
33
|
+
* from then on; otherwise the macro renders nothing.
|
|
34
|
+
*/
|
|
35
|
+
export function useRunOnce(macro: string, rawArgs: string, effect: () => void) {
|
|
36
|
+
// Unset until the effect has run. Boxed: anything can be thrown,
|
|
37
|
+
// including null and other falsy values.
|
|
38
|
+
const failure = useRef<{ error: unknown } | null>();
|
|
39
|
+
if (failure.current === undefined) {
|
|
40
|
+
failure.current = null;
|
|
41
|
+
try {
|
|
42
|
+
effect();
|
|
43
|
+
} catch (error) {
|
|
44
|
+
failure.current = { error };
|
|
45
|
+
logMacroError(`${macro} ${rawArgs}`, error);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (!failure.current) return null;
|
|
50
|
+
return (
|
|
51
|
+
<MacroError
|
|
52
|
+
macro={macro}
|
|
53
|
+
error={failure.current.error}
|
|
54
|
+
/>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
@@ -1,36 +1,21 @@
|
|
|
1
1
|
import { useStoryStore } from '../../store';
|
|
2
2
|
import { defineMacro } from '../../define-macro';
|
|
3
|
-
import { unescapeQuoted } from './arg-utils';
|
|
4
3
|
import { useDetachedBody } from './detached-body';
|
|
5
4
|
|
|
6
|
-
export function parseLinkArgs(rawArgs: string): {
|
|
7
|
-
display: string;
|
|
8
|
-
passage: string | null;
|
|
9
|
-
} {
|
|
10
|
-
// {link "text" "Passage"} or {link "text"}. A backslash escapes a quote
|
|
11
|
-
// or another backslash inside a quoted argument (#200).
|
|
12
|
-
const parts: string[] = [];
|
|
13
|
-
const re = /(["'])((?:\\[^]|(?!\1)[^\\])*)\1/g;
|
|
14
|
-
let m;
|
|
15
|
-
while ((m = re.exec(rawArgs)) !== null) {
|
|
16
|
-
parts.push(unescapeQuoted(m[2]!));
|
|
17
|
-
}
|
|
18
|
-
if (parts.length >= 2) {
|
|
19
|
-
return { display: parts[0]!, passage: parts[1]! };
|
|
20
|
-
}
|
|
21
|
-
if (parts.length === 1) {
|
|
22
|
-
return { display: parts[0]!, passage: null };
|
|
23
|
-
}
|
|
24
|
-
// Fallback: treat entire rawArgs as display text
|
|
25
|
-
return { display: rawArgs.trim(), passage: null };
|
|
26
|
-
}
|
|
27
|
-
|
|
28
5
|
defineMacro({
|
|
29
6
|
name: 'link',
|
|
30
7
|
block: true,
|
|
31
8
|
interpolate: true,
|
|
9
|
+
// {link "text" "Passage"} or {link "text"}; unquoted, the whole arguments
|
|
10
|
+
// are the text.
|
|
11
|
+
parameters: [
|
|
12
|
+
{ name: 'text', type: 'string', required: true },
|
|
13
|
+
{ name: 'passage', type: 'string' },
|
|
14
|
+
],
|
|
32
15
|
render({ rawArgs, children = [] }, ctx) {
|
|
33
|
-
const {
|
|
16
|
+
const { text } = ctx.args;
|
|
17
|
+
const display = text ?? rawArgs.trim();
|
|
18
|
+
const passage = text === undefined ? null : (ctx.args.passage ?? null);
|
|
34
19
|
const runBody = useDetachedBody();
|
|
35
20
|
|
|
36
21
|
const perform = () => {
|
|
@@ -1,46 +1,21 @@
|
|
|
1
1
|
import { defineMacro } from '../../define-macro';
|
|
2
2
|
import { MacroError } from './MacroError';
|
|
3
|
-
import {
|
|
4
|
-
endsWithOperator,
|
|
5
|
-
isWhitespace,
|
|
6
|
-
readWholeQuoted,
|
|
7
|
-
splitTopLevel,
|
|
8
|
-
} from './arg-utils';
|
|
9
3
|
|
|
10
|
-
/**
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
export function parseMeterArgs(rawArgs: string): {
|
|
18
|
-
currentExpr: string;
|
|
19
|
-
maxExpr: string;
|
|
20
|
-
labelMode: string;
|
|
21
|
-
} {
|
|
22
|
-
const tokens = splitTopLevel(rawArgs.trim(), isWhitespace).filter(Boolean);
|
|
23
|
-
|
|
24
|
-
let labelMode = '';
|
|
25
|
-
const label =
|
|
26
|
-
tokens.length >= 2 && !endsWithOperator(tokens.slice(0, -1).join(' '))
|
|
27
|
-
? readWholeQuoted(tokens[tokens.length - 1]!)
|
|
28
|
-
: null;
|
|
29
|
-
if (label !== null) {
|
|
30
|
-
labelMode = label;
|
|
31
|
-
tokens.pop();
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
if (tokens.length < 2) {
|
|
4
|
+
/** Check the arguments of `{meter currentExpr maxExpr ["label"]}`. */
|
|
5
|
+
export function meterArgs(args: {
|
|
6
|
+
current?: string;
|
|
7
|
+
max?: string;
|
|
8
|
+
label?: string;
|
|
9
|
+
}): { currentExpr: string; maxExpr: string; labelMode: string } {
|
|
10
|
+
if (args.current === undefined || args.max === undefined) {
|
|
35
11
|
throw new Error(
|
|
36
12
|
'meter requires two arguments: {meter currentExpr maxExpr}',
|
|
37
13
|
);
|
|
38
14
|
}
|
|
39
|
-
|
|
40
15
|
return {
|
|
41
|
-
currentExpr:
|
|
42
|
-
maxExpr:
|
|
43
|
-
labelMode,
|
|
16
|
+
currentExpr: args.current,
|
|
17
|
+
maxExpr: args.max,
|
|
18
|
+
labelMode: args.label ?? '',
|
|
44
19
|
};
|
|
45
20
|
}
|
|
46
21
|
|
|
@@ -60,9 +35,16 @@ defineMacro({
|
|
|
60
35
|
name: 'meter',
|
|
61
36
|
interpolate: true,
|
|
62
37
|
merged: true,
|
|
63
|
-
|
|
38
|
+
// {meter currentExpr maxExpr ["label"]}. A string after an operator
|
|
39
|
+
// (`$a ?? "5"`) is an operand of the max expression, not the label.
|
|
40
|
+
parameters: [
|
|
41
|
+
{ name: 'current', type: 'expression', required: true },
|
|
42
|
+
{ name: 'max', type: 'expression', required: true },
|
|
43
|
+
{ name: 'label', type: 'string' },
|
|
44
|
+
],
|
|
45
|
+
render(_props, ctx) {
|
|
64
46
|
try {
|
|
65
|
-
const { currentExpr, maxExpr, labelMode } =
|
|
47
|
+
const { currentExpr, maxExpr, labelMode } = meterArgs(ctx.args);
|
|
66
48
|
const current = Number(ctx.evaluate!(currentExpr));
|
|
67
49
|
const max = Number(ctx.evaluate!(maxExpr));
|
|
68
50
|
const pct =
|
|
@@ -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
|
|