@rohal12/spindle 0.59.11 → 0.59.13
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 +7293 -7141
- package/dist/pkg/story-variables.js +2 -1
- package/package.json +1 -1
- package/src/components/macros/Computed.tsx +26 -12
- package/src/components/macros/For.tsx +37 -1
- package/src/components/macros/Type.tsx +8 -4
- package/src/components/macros/Widget.tsx +1 -19
- package/src/components/macros/WidgetInvocation.tsx +3 -10
- package/src/define-macro.ts +2 -0
- package/src/execute-mutation.ts +26 -0
- package/src/hooks/use-modal-focus.ts +66 -4
- package/src/index.tsx +9 -22
- package/src/markup/render.tsx +43 -4
- package/src/saves/save-manager.ts +9 -0
- package/src/story-api.ts +2 -1
- package/src/story-init.ts +4 -0
- package/src/story-variables.ts +12 -2
- package/src/utils/control-edits.ts +48 -0
- package/src/widgets/register-widget-def.ts +44 -0
- package/types/tooling.d.ts +7 -1
|
@@ -11082,7 +11082,8 @@ var VALID_VAR_TYPES = new Set([
|
|
|
11082
11082
|
]);
|
|
11083
11083
|
function inferSchema(value) {
|
|
11084
11084
|
if (Array.isArray(value)) return { type: "array" };
|
|
11085
|
-
if (value
|
|
11085
|
+
if (value === null) return { type: "null" };
|
|
11086
|
+
if (typeof value === "object") {
|
|
11086
11087
|
const fields = /* @__PURE__ */ new Map();
|
|
11087
11088
|
for (const [key, val] of Object.entries(value)) fields.set(key, inferSchema(val));
|
|
11088
11089
|
return {
|
package/package.json
CHANGED
|
@@ -5,7 +5,7 @@ import { deepEqualStrict } from '../../structural';
|
|
|
5
5
|
import { defineMacro } from '../../define-macro';
|
|
6
6
|
import { MacroError, logMacroError } from './MacroError';
|
|
7
7
|
import { currentSourceLocation } from '../../utils/source-location';
|
|
8
|
-
import { checkVariableName } from '../../utils/namespace';
|
|
8
|
+
import { checkVariableName, ownValue } from '../../utils/namespace';
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* The previous output before the first successful evaluation. Distinct from
|
|
@@ -39,7 +39,7 @@ function assignmentArgs(
|
|
|
39
39
|
/**
|
|
40
40
|
* Evaluate `expr` against the current values — the store's state and the
|
|
41
41
|
* enclosing locals scope's live values (`getLocals`) — and write the result
|
|
42
|
-
* to the target if it differs from the previous output (always, the first
|
|
42
|
+
* to the target if it differs from the previous output or the target's value (always, the first
|
|
43
43
|
* time). Reading live values rather than the render's snapshot means the
|
|
44
44
|
* first computation sees a local a preceding {set} just assigned (the scope's
|
|
45
45
|
* context value only catches up on its re-render), and the first computation
|
|
@@ -53,22 +53,39 @@ function computeAndApply(
|
|
|
53
53
|
getLocals: () => Record<string, unknown>,
|
|
54
54
|
rawArgs: string,
|
|
55
55
|
prevRef: { current: unknown },
|
|
56
|
+
lastNavigation: { current: number },
|
|
56
57
|
localsUpdate: ((key: string, value: unknown) => void) | null,
|
|
57
58
|
): void {
|
|
58
59
|
// Before evaluating: the expression may navigate away from this passage
|
|
59
60
|
const location = currentSourceLocation();
|
|
60
61
|
let newValue: unknown;
|
|
62
|
+
let current: unknown = prevRef.current;
|
|
63
|
+
let navigated = false;
|
|
64
|
+
const { navigationId } = useStoryStore.getState();
|
|
61
65
|
try {
|
|
62
66
|
// In program order, also when mutation code sets this off
|
|
63
67
|
const { variables, temporary, transient } = readState();
|
|
68
|
+
// A navigation (or restored session) resets the temporary namespace,
|
|
69
|
+
// which a persistent {computed} survives: the target is then gone though
|
|
70
|
+
// the result is unchanged, and is written again. Between navigations a
|
|
71
|
+
// target the story removed or set itself stays as it is.
|
|
72
|
+
if (!isLocal) {
|
|
73
|
+
current = ownValue(isTemp ? temporary : variables, name);
|
|
74
|
+
navigated = navigationId !== lastNavigation.current;
|
|
75
|
+
}
|
|
64
76
|
newValue = evaluate(expr, variables, temporary, getLocals(), transient);
|
|
65
77
|
} catch (err) {
|
|
66
78
|
logMacroError(`computed ${rawArgs}`, err, location);
|
|
67
79
|
return;
|
|
68
80
|
}
|
|
69
81
|
|
|
82
|
+
lastNavigation.current = navigationId;
|
|
83
|
+
|
|
70
84
|
// Strict: a change of only the aliases inside the result is a change
|
|
71
|
-
if (
|
|
85
|
+
if (
|
|
86
|
+
!deepEqualStrict(prevRef.current, newValue) ||
|
|
87
|
+
(navigated && !deepEqualStrict(current, newValue))
|
|
88
|
+
) {
|
|
72
89
|
prevRef.current = newValue;
|
|
73
90
|
if (isLocal) {
|
|
74
91
|
try {
|
|
@@ -93,8 +110,6 @@ defineMacro({
|
|
|
93
110
|
{ name: 'expression', type: 'expression', required: true },
|
|
94
111
|
],
|
|
95
112
|
render({ rawArgs }, ctx) {
|
|
96
|
-
const [mergedVars, mergedTemps, mergedLocals, mergedTrans] = ctx.merged!;
|
|
97
|
-
|
|
98
113
|
let target: string;
|
|
99
114
|
let expr: string;
|
|
100
115
|
try {
|
|
@@ -113,6 +128,8 @@ defineMacro({
|
|
|
113
128
|
const localsUpdate = isLocal ? ctx.update : null;
|
|
114
129
|
|
|
115
130
|
const prevOutput = ctx.hooks.useRef<unknown>(UNSET);
|
|
131
|
+
// The navigation the target was last checked in
|
|
132
|
+
const lastNavigation = ctx.hooks.useRef(-1);
|
|
116
133
|
|
|
117
134
|
const compute = () =>
|
|
118
135
|
computeAndApply(
|
|
@@ -123,6 +140,7 @@ defineMacro({
|
|
|
123
140
|
ctx.getValues,
|
|
124
141
|
rawArgs,
|
|
125
142
|
prevOutput,
|
|
143
|
+
lastNavigation,
|
|
126
144
|
localsUpdate,
|
|
127
145
|
);
|
|
128
146
|
|
|
@@ -133,13 +151,9 @@ defineMacro({
|
|
|
133
151
|
}
|
|
134
152
|
|
|
135
153
|
// The merged values only decide when to recompute; computeAndApply reads
|
|
136
|
-
// the current ones.
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
mergedTemps,
|
|
140
|
-
mergedLocals,
|
|
141
|
-
mergedTrans,
|
|
142
|
-
]);
|
|
154
|
+
// the current ones. The tuple is renewed with them and with the render
|
|
155
|
+
// counts, which rendered() / hasRendered() read.
|
|
156
|
+
ctx.hooks.useLayoutEffect(compute, [ctx.merged]);
|
|
143
157
|
|
|
144
158
|
return null;
|
|
145
159
|
},
|
|
@@ -3,8 +3,10 @@ import { LocalsValuesContext } from '../../markup/render';
|
|
|
3
3
|
import { defineMacro } from '../../define-macro';
|
|
4
4
|
import { MacroError } from './MacroError';
|
|
5
5
|
import { stableKey } from '../../utils/stable-key';
|
|
6
|
+
import { controlEditVersion, editedItem } from '../../utils/control-edits';
|
|
6
7
|
import type { ASTNode } from '../../markup/ast';
|
|
7
8
|
import { checkVariableName } from '../../utils/namespace';
|
|
9
|
+
import { readState } from '../../execute-mutation';
|
|
8
10
|
import { LocalsScope } from './locals-scope';
|
|
9
11
|
|
|
10
12
|
/**
|
|
@@ -76,6 +78,14 @@ function ForIteration({
|
|
|
76
78
|
);
|
|
77
79
|
}
|
|
78
80
|
|
|
81
|
+
/** What {for} remembers of its iterations between renders. */
|
|
82
|
+
interface IterationKeys {
|
|
83
|
+
contents: string[];
|
|
84
|
+
generations: number[];
|
|
85
|
+
next: number;
|
|
86
|
+
edits: number;
|
|
87
|
+
}
|
|
88
|
+
|
|
79
89
|
defineMacro({
|
|
80
90
|
name: 'for',
|
|
81
91
|
block: true,
|
|
@@ -88,6 +98,12 @@ defineMacro({
|
|
|
88
98
|
],
|
|
89
99
|
render({ rawArgs, children = [] }, ctx) {
|
|
90
100
|
const parentValues = useContext(LocalsValuesContext);
|
|
101
|
+
const keys = ctx.hooks.useRef<IterationKeys>({
|
|
102
|
+
contents: [],
|
|
103
|
+
generations: [],
|
|
104
|
+
next: 0,
|
|
105
|
+
edits: controlEditVersion(),
|
|
106
|
+
}).current;
|
|
91
107
|
|
|
92
108
|
let loop: ReturnType<typeof loopArgs>;
|
|
93
109
|
let list: unknown[];
|
|
@@ -112,9 +128,29 @@ defineMacro({
|
|
|
112
128
|
}
|
|
113
129
|
|
|
114
130
|
const { itemVar, indexVar } = loop;
|
|
131
|
+
// An iteration is remounted when its item's contents change, so mount-only
|
|
132
|
+
// macros run again for the new item (#45) -- unless the change is a
|
|
133
|
+
// reader editing the item through a control: remounting would drop the
|
|
134
|
+
// control's focus after the first keystroke.
|
|
135
|
+
const since = keys.edits;
|
|
136
|
+
const { variables } = readState();
|
|
137
|
+
keys.edits = controlEditVersion();
|
|
138
|
+
const generations = list.map((item, i) => {
|
|
139
|
+
const contents = stableKey(item);
|
|
140
|
+
if (
|
|
141
|
+
keys.generations[i] === undefined ||
|
|
142
|
+
(contents !== keys.contents[i] &&
|
|
143
|
+
!editedItem(since, variables, list, item, i))
|
|
144
|
+
) {
|
|
145
|
+
keys.generations[i] = keys.next++;
|
|
146
|
+
}
|
|
147
|
+
keys.contents[i] = contents;
|
|
148
|
+
return keys.generations[i]!;
|
|
149
|
+
});
|
|
150
|
+
keys.contents.length = keys.generations.length = list.length;
|
|
115
151
|
const content = list.map((item, i) => (
|
|
116
152
|
<ForIteration
|
|
117
|
-
key={`${i}-${
|
|
153
|
+
key={`${i}-${generations[i]}`}
|
|
118
154
|
parentValues={parentValues}
|
|
119
155
|
itemVar={itemVar}
|
|
120
156
|
itemValue={item}
|
|
@@ -49,7 +49,10 @@ defineMacro({
|
|
|
49
49
|
const containerRef = useRef<HTMLSpanElement>(null);
|
|
50
50
|
const innerRef = useRef<HTMLSpanElement>(null);
|
|
51
51
|
const [clip, setClip] = useState<string | undefined>();
|
|
52
|
-
|
|
52
|
+
// Null until the content has been measured: zero characters is then a
|
|
53
|
+
// result (nothing to animate), not a pending measurement.
|
|
54
|
+
const [measured, setMeasured] = useState<number | null>(null);
|
|
55
|
+
const totalChars = measured ?? 0;
|
|
53
56
|
const [visibleChars, setVisibleChars] = useState(0);
|
|
54
57
|
const visibleCharsRef = useRef(0);
|
|
55
58
|
visibleCharsRef.current = visibleChars;
|
|
@@ -59,7 +62,7 @@ defineMacro({
|
|
|
59
62
|
useEffect(() => {
|
|
60
63
|
if (containerRef.current) {
|
|
61
64
|
const text = containerRef.current.textContent || '';
|
|
62
|
-
|
|
65
|
+
setMeasured(text.length);
|
|
63
66
|
}
|
|
64
67
|
});
|
|
65
68
|
|
|
@@ -81,7 +84,8 @@ defineMacro({
|
|
|
81
84
|
return () => clearInterval(timer);
|
|
82
85
|
}, [totalChars, speed]);
|
|
83
86
|
|
|
84
|
-
|
|
87
|
+
// Empty text can still become reactive text later, which restarts typing
|
|
88
|
+
const done = measured !== null && visibleChars >= totalChars;
|
|
85
89
|
|
|
86
90
|
// Clip to the characters revealed so far, as laid out: remeasured when
|
|
87
91
|
// the count changes and when the width the text wraps in does.
|
|
@@ -117,7 +121,7 @@ defineMacro({
|
|
|
117
121
|
class="macro-type-inner"
|
|
118
122
|
style={{
|
|
119
123
|
display: 'inline',
|
|
120
|
-
visibility:
|
|
124
|
+
visibility: measured === null ? 'hidden' : 'visible',
|
|
121
125
|
clipPath: clip,
|
|
122
126
|
}}
|
|
123
127
|
>
|
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { astContainsChildren } from '../../widgets/ast-scanner';
|
|
3
|
-
import { registerBlockMacro, type ASTNode } from '../../markup/ast';
|
|
1
|
+
import { registerWidgetDef } from '../../widgets/register-widget-def';
|
|
4
2
|
import { defineMacro } from '../../define-macro';
|
|
5
3
|
import { checkVariableName } from '../../utils/namespace';
|
|
6
4
|
import { MacroError } from './MacroError';
|
|
@@ -10,22 +8,6 @@ import {
|
|
|
10
8
|
type WidgetDef,
|
|
11
9
|
} from '../../widgets/widget-def';
|
|
12
10
|
|
|
13
|
-
/**
|
|
14
|
-
* Register the widget a definition declares, with its body. Widgets whose
|
|
15
|
-
* body renders {@children} take a closing tag, so they are registered as
|
|
16
|
-
* block macros too: passages parsed later nest their content. A parameter
|
|
17
|
-
* that no namespace can hold throws (see registerWidget), registering
|
|
18
|
-
* nothing.
|
|
19
|
-
*/
|
|
20
|
-
export function registerWidgetDef(
|
|
21
|
-
{ name, params }: WidgetDef,
|
|
22
|
-
body: ASTNode[],
|
|
23
|
-
): void {
|
|
24
|
-
const isBlock = astContainsChildren(body);
|
|
25
|
-
registerWidget(name, body, params, isBlock);
|
|
26
|
-
if (isBlock) registerBlockMacro(name);
|
|
27
|
-
}
|
|
28
|
-
|
|
29
11
|
defineMacro({
|
|
30
12
|
name: 'widget',
|
|
31
13
|
block: true,
|
|
@@ -46,16 +46,9 @@ function WidgetContent({
|
|
|
46
46
|
|
|
47
47
|
const childrenValue = invocationChildren?.length ? invocationChildren : null;
|
|
48
48
|
|
|
49
|
-
//
|
|
50
|
-
// without arguments: missing parameters shadow
|
|
51
|
-
|
|
52
|
-
return (
|
|
53
|
-
<WidgetChildrenContext.Provider value={childrenValue}>
|
|
54
|
-
{renderNodes(body, renderOptions)}
|
|
55
|
-
</WidgetChildrenContext.Provider>
|
|
56
|
-
);
|
|
57
|
-
}
|
|
58
|
-
|
|
49
|
+
// Every widget gets its own local scope, even when it declares no
|
|
50
|
+
// parameters or is invoked without arguments: missing parameters shadow
|
|
51
|
+
// outer locals as undefined, and the body's own locals stay in the widget.
|
|
59
52
|
const argExprs = rawArgs ? splitArgs(rawArgs) : [];
|
|
60
53
|
const values: unknown[] = [];
|
|
61
54
|
|
package/src/define-macro.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
useMemo,
|
|
11
11
|
} from 'preact/hooks';
|
|
12
12
|
import { useInterpolate } from './hooks/use-interpolate';
|
|
13
|
+
import { noteControlEdit } from './utils/control-edits';
|
|
13
14
|
import { useMergedLocals } from './hooks/use-merged-locals';
|
|
14
15
|
import { useRenderOptions } from './hooks/use-render-options';
|
|
15
16
|
import {
|
|
@@ -228,6 +229,7 @@ export function defineMacro<const P extends readonly ParameterDef[] = []>(
|
|
|
228
229
|
// In program order, also when mutation code performs the input
|
|
229
230
|
ctx.getValue = () => getByPath(readState().variables, segments);
|
|
230
231
|
ctx.setValue = (value: unknown) => {
|
|
232
|
+
noteControlEdit(segments);
|
|
231
233
|
// The commit mutation code uses, which keeps the references among
|
|
232
234
|
// variables: a write to a draft copies only the path written, and
|
|
233
235
|
// other variables sharing the object would keep the old one.
|
package/src/execute-mutation.ts
CHANGED
|
@@ -138,6 +138,32 @@ export function frozenCopy<T>(value: T): T {
|
|
|
138
138
|
return freeze(cloneValue(value), true);
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
+
/** Whether `value` and everything it holds is frozen. */
|
|
142
|
+
function deeplyFrozen(value: unknown, seen = new Set<object>()): boolean {
|
|
143
|
+
if (typeof value !== 'object' || value === null || seen.has(value)) {
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
// Leaves a freeze cannot lock (typed array elements) and that a copy would
|
|
147
|
+
// detach from what they share (a buffer): handed out as they are
|
|
148
|
+
if (ArrayBuffer.isView(value) || value instanceof ArrayBuffer) return true;
|
|
149
|
+
if (!Object.isFrozen(value)) return false;
|
|
150
|
+
seen.add(value);
|
|
151
|
+
return Reflect.ownKeys(value).every((key) => {
|
|
152
|
+
const desc = Object.getOwnPropertyDescriptor(value, key);
|
|
153
|
+
return !desc || !('value' in desc) || deeplyFrozen(desc.value, seen);
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* A value read from the store, made safe to hand out: the store holds plain
|
|
159
|
+
* values frozen, but not registered class instances, whose methods could
|
|
160
|
+
* change the recorded history through the live object. Such a value is
|
|
161
|
+
* returned as a frozen copy, like a read inside mutation code.
|
|
162
|
+
*/
|
|
163
|
+
export function readOnlyValue<T>(value: T): T {
|
|
164
|
+
return deeplyFrozen(value) ? value : frozenCopy(value);
|
|
165
|
+
}
|
|
166
|
+
|
|
141
167
|
/**
|
|
142
168
|
* Apply a write that reached the store while mutations execute to their
|
|
143
169
|
* `work` and `base` copies (see MutationScope), so the code sees it and
|
|
@@ -9,8 +9,9 @@ const FOCUSABLE = [
|
|
|
9
9
|
'select:not([disabled])',
|
|
10
10
|
'textarea:not([disabled])',
|
|
11
11
|
'iframe',
|
|
12
|
-
'
|
|
13
|
-
'[
|
|
12
|
+
'summary',
|
|
13
|
+
'[contenteditable]',
|
|
14
|
+
'[tabindex]',
|
|
14
15
|
].join(',');
|
|
15
16
|
|
|
16
17
|
/**
|
|
@@ -27,15 +28,76 @@ function isRendered(el: HTMLElement): boolean {
|
|
|
27
28
|
return true;
|
|
28
29
|
}
|
|
29
30
|
|
|
31
|
+
/** The `tabindex` of `el` as an integer, or null when absent or invalid. */
|
|
32
|
+
function tabIndexAttr(el: HTMLElement): number | null {
|
|
33
|
+
const raw = el.getAttribute('tabindex');
|
|
34
|
+
if (raw === null || !/^\s*[+-]?\d+\s*$/.test(raw)) return null;
|
|
35
|
+
return parseInt(raw, 10);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Whether the tab key can reach `el`: a negative tabindex only allows
|
|
40
|
+
* scripted focus, and of a radio group the browser tabs to the checked
|
|
41
|
+
* member only (the first one when none is checked).
|
|
42
|
+
*/
|
|
43
|
+
function isTabStop(el: HTMLElement): boolean {
|
|
44
|
+
const tabIndex = tabIndexAttr(el);
|
|
45
|
+
if (tabIndex !== null && tabIndex < 0) return false;
|
|
46
|
+
// Only an editing host takes focus, not `contenteditable="false"` or
|
|
47
|
+
// content nested in a host
|
|
48
|
+
if (
|
|
49
|
+
el.hasAttribute('contenteditable') &&
|
|
50
|
+
!el.matches('a[href], button, input, select, textarea, iframe, summary') &&
|
|
51
|
+
(!el.isContentEditable || el.parentElement?.isContentEditable)
|
|
52
|
+
) {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
// Only a details element's first summary child is its toggle
|
|
56
|
+
if (el.localName === 'summary') {
|
|
57
|
+
const details = el.parentElement;
|
|
58
|
+
if (
|
|
59
|
+
details?.localName !== 'details' ||
|
|
60
|
+
Array.from(details.children).find((c) => c.localName === 'summary') !== el
|
|
61
|
+
) {
|
|
62
|
+
return tabIndex !== null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (el instanceof HTMLInputElement && el.type === 'radio' && el.name) {
|
|
66
|
+
const group = Array.from(
|
|
67
|
+
(
|
|
68
|
+
el.form ?? (el.getRootNode() as ParentNode)
|
|
69
|
+
).querySelectorAll<HTMLInputElement>('input[type="radio"]'),
|
|
70
|
+
).filter((r) => r.name === el.name && r.form === el.form && !r.disabled);
|
|
71
|
+
const stop = group.find((r) => r.checked) ?? group[0];
|
|
72
|
+
return el === stop;
|
|
73
|
+
}
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The elements of `root` in the order Tab visits them: positive tabindexes
|
|
79
|
+
* first, ascending, then the others in document order.
|
|
80
|
+
*/
|
|
30
81
|
function focusables(root: HTMLElement): HTMLElement[] {
|
|
31
|
-
|
|
82
|
+
const stops = Array.from(
|
|
83
|
+
root.querySelectorAll<HTMLElement>(FOCUSABLE),
|
|
84
|
+
).filter(
|
|
32
85
|
(el) =>
|
|
33
86
|
!el.hidden &&
|
|
34
87
|
!el.closest('[hidden], [inert]') &&
|
|
35
88
|
// Also covers controls disabled by a <fieldset disabled> ancestor
|
|
36
89
|
!el.matches(':disabled') &&
|
|
37
|
-
isRendered(el)
|
|
90
|
+
isRendered(el) &&
|
|
91
|
+
isTabStop(el),
|
|
38
92
|
);
|
|
93
|
+
const positive = (el: HTMLElement) => Math.max(tabIndexAttr(el) ?? 0, 0);
|
|
94
|
+
// Array.prototype.sort is stable: equal tabindexes keep document order
|
|
95
|
+
return [
|
|
96
|
+
...stops
|
|
97
|
+
.filter((el) => positive(el) > 0)
|
|
98
|
+
.sort((a, b) => positive(a) - positive(b)),
|
|
99
|
+
...stops.filter((el) => positive(el) === 0),
|
|
100
|
+
];
|
|
39
101
|
}
|
|
40
102
|
|
|
41
103
|
interface ModalEntry {
|
package/src/index.tsx
CHANGED
|
@@ -27,7 +27,7 @@ import {
|
|
|
27
27
|
import { blockWidgetNames, parseWidgetDef } from './widgets/widget-def';
|
|
28
28
|
import { parseMarkup } from './markup/parse';
|
|
29
29
|
import { registerBlockMacro } from './markup/ast';
|
|
30
|
-
import {
|
|
30
|
+
import { registerWidgetDefinitions } from './widgets/register-widget-def';
|
|
31
31
|
import { errorMessage } from './utils/error-message';
|
|
32
32
|
import type { ASTNode } from './markup/ast';
|
|
33
33
|
import './macros/register-builtins';
|
|
@@ -193,6 +193,14 @@ export function boot() {
|
|
|
193
193
|
// Enter runtime phase — handlers registered from here on are cleaned on restart
|
|
194
194
|
enterRuntimePhase();
|
|
195
195
|
|
|
196
|
+
// Pass 2: Full parse and register widgets from passages tagged "widget",
|
|
197
|
+
// before StoryInit runs so that it can invoke them
|
|
198
|
+
for (const [, passage] of storyData.passages) {
|
|
199
|
+
if (passage.tags.includes('widget')) {
|
|
200
|
+
registerWidgetDefinitions(parseMarkup(passage.content), passage.name);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
196
204
|
// Run StoryInit, restore the session if the page was refreshed, and fire
|
|
197
205
|
// storyinit after all state is settled (defaults + StoryInit + session)
|
|
198
206
|
initializeStory(loadSession(storyData.ifid));
|
|
@@ -205,27 +213,6 @@ export function boot() {
|
|
|
205
213
|
];
|
|
206
214
|
if (markup.length > 0) stopWithErrors(markup);
|
|
207
215
|
|
|
208
|
-
// Pass 2: Full parse and register widgets from passages tagged "widget"
|
|
209
|
-
for (const [, passage] of storyData.passages) {
|
|
210
|
-
if (passage.tags.includes('widget')) {
|
|
211
|
-
const widgetAST = parseMarkup(passage.content);
|
|
212
|
-
for (const node of widgetAST) {
|
|
213
|
-
if (node.type === 'macro' && node.name === 'widget' && node.rawArgs) {
|
|
214
|
-
// Read and registered as the {widget} macro does
|
|
215
|
-
const def = parseWidgetDef(node.rawArgs);
|
|
216
|
-
try {
|
|
217
|
-
registerWidgetDef(def, node.children as ASTNode[]);
|
|
218
|
-
} catch (err) {
|
|
219
|
-
// As the {widget} macro refuses it: the others still register
|
|
220
|
-
console.error(
|
|
221
|
-
`spindle: widget "${def.name}" in passage "${passage.name}" was not registered: ${errorMessage(err)}`,
|
|
222
|
-
);
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
|
|
229
216
|
// Reset action ID counters on every navigation (the passage remounts even
|
|
230
217
|
// when its name is unchanged). Controls that stay mounted, e.g. in
|
|
231
218
|
// StoryInterface, keep their IDs: allocation skips IDs still registered.
|
package/src/markup/render.tsx
CHANGED
|
@@ -52,7 +52,7 @@ export const InlineContext = createContext(false);
|
|
|
52
52
|
/**
|
|
53
53
|
* True while rendering inside an element whose content is not markdown: SVG
|
|
54
54
|
* (whose namespace `<p>` wrappers would break) and the preformatted `<pre>`
|
|
55
|
-
* and `<
|
|
55
|
+
* `<textarea>` and `<style>`, whose text (indentation, `#`, `*`, ...) is literal.
|
|
56
56
|
* Macro and widget bodies read it so their content stays literal too.
|
|
57
57
|
*/
|
|
58
58
|
export const RawTextContext = createContext(false);
|
|
@@ -66,7 +66,7 @@ const SvgContext = createContext(false);
|
|
|
66
66
|
const SVG_NAMESPACE = 'http://www.w3.org/2000/svg';
|
|
67
67
|
|
|
68
68
|
/** Elements whose content is literal text, not markdown. */
|
|
69
|
-
const PREFORMATTED_ELEMENTS = new Set(['pre', 'textarea']);
|
|
69
|
+
const PREFORMATTED_ELEMENTS = new Set(['pre', 'textarea', 'style']);
|
|
70
70
|
export const WidgetChildrenContext = createContext<ASTNode[] | null>(null);
|
|
71
71
|
|
|
72
72
|
/**
|
|
@@ -348,7 +348,11 @@ function convertDomNode(
|
|
|
348
348
|
return null;
|
|
349
349
|
}
|
|
350
350
|
|
|
351
|
-
/**
|
|
351
|
+
/**
|
|
352
|
+
* Elements whose content is phrasing content only, so block-level markdown
|
|
353
|
+
* (lists, headings, paragraphs) is invalid in them: inline elements, and
|
|
354
|
+
* block elements such as headings and buttons that only take inline children.
|
|
355
|
+
*/
|
|
352
356
|
const INLINE_ELEMENTS = new Set([
|
|
353
357
|
'a',
|
|
354
358
|
'abbr',
|
|
@@ -356,17 +360,27 @@ const INLINE_ELEMENTS = new Set([
|
|
|
356
360
|
'bdi',
|
|
357
361
|
'bdo',
|
|
358
362
|
'br',
|
|
363
|
+
'button',
|
|
359
364
|
'cite',
|
|
360
365
|
'code',
|
|
361
366
|
'data',
|
|
362
367
|
'dfn',
|
|
363
368
|
'em',
|
|
364
369
|
'i',
|
|
370
|
+
'h1',
|
|
371
|
+
'h2',
|
|
372
|
+
'h3',
|
|
373
|
+
'h4',
|
|
374
|
+
'h5',
|
|
375
|
+
'h6',
|
|
365
376
|
'kbd',
|
|
366
377
|
'label',
|
|
378
|
+
'legend',
|
|
367
379
|
'mark',
|
|
368
380
|
'meter',
|
|
381
|
+
'option',
|
|
369
382
|
'output',
|
|
383
|
+
'p',
|
|
370
384
|
'progress',
|
|
371
385
|
'q',
|
|
372
386
|
'rp',
|
|
@@ -378,6 +392,7 @@ const INLINE_ELEMENTS = new Set([
|
|
|
378
392
|
'span',
|
|
379
393
|
'strong',
|
|
380
394
|
'sub',
|
|
395
|
+
'summary',
|
|
381
396
|
'sup',
|
|
382
397
|
'time',
|
|
383
398
|
'u',
|
|
@@ -618,6 +633,15 @@ function resolveAttributeValue(
|
|
|
618
633
|
return result.text;
|
|
619
634
|
}
|
|
620
635
|
|
|
636
|
+
/** The index of the summary element opening `nodes`, or -1 when none does. */
|
|
637
|
+
function leadingSummaryIndex(nodes: ASTNode[]): number {
|
|
638
|
+
const at = nodes.findIndex((n) => n.type !== 'text' || n.value.trim() !== '');
|
|
639
|
+
const first = nodes[at];
|
|
640
|
+
return first?.type === 'html' && first.tag.toLowerCase() === 'summary'
|
|
641
|
+
? at
|
|
642
|
+
: -1;
|
|
643
|
+
}
|
|
644
|
+
|
|
621
645
|
function HtmlNodeRenderer({ node }: { node: HtmlNode }) {
|
|
622
646
|
const scope = useTextScope();
|
|
623
647
|
const { nobr, locals, raw: inRaw, inline: parentInline } = useRenderOptions();
|
|
@@ -644,7 +668,22 @@ function HtmlNodeRenderer({ node }: { node: HtmlNode }) {
|
|
|
644
668
|
if (inRaw || isRawRoot) {
|
|
645
669
|
children = renderInlineNodes(node.children);
|
|
646
670
|
} else {
|
|
647
|
-
|
|
671
|
+
// A details element's summary stays its direct child: it is rendered
|
|
672
|
+
// apart from the markdown of the rest of the content.
|
|
673
|
+
const summaryAt =
|
|
674
|
+
tag === 'details' ? leadingSummaryIndex(node.children) : -1;
|
|
675
|
+
const body = (nodes: ASTNode[]) =>
|
|
676
|
+
renderNodes(nodes, { nobr, locals, inline: isInline });
|
|
677
|
+
children =
|
|
678
|
+
summaryAt === -1 ? (
|
|
679
|
+
body(node.children)
|
|
680
|
+
) : (
|
|
681
|
+
<>
|
|
682
|
+
{body(node.children.slice(0, summaryAt))}
|
|
683
|
+
{renderSingleNode(node.children[summaryAt]!)}
|
|
684
|
+
{body(node.children.slice(summaryAt + 1))}
|
|
685
|
+
</>
|
|
686
|
+
);
|
|
648
687
|
if (isInline !== parentInline) {
|
|
649
688
|
children = (
|
|
650
689
|
<InlineContext.Provider value={isInline}>
|
|
@@ -36,7 +36,16 @@ export function setSaveTitlePassage(content: string): void {
|
|
|
36
36
|
saveTitlePassageContent = content;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
/**
|
|
40
|
+
* The title of a save. The SaveTitle passage and title generators may draw
|
|
41
|
+
* random numbers; like the save hooks they must not advance the story's PRNG
|
|
42
|
+
* (see withoutDraws()).
|
|
43
|
+
*/
|
|
39
44
|
function generateTitle(payload: SavePayload): string {
|
|
45
|
+
return withoutDraws(() => generateTitleNow(payload));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function generateTitleNow(payload: SavePayload): string {
|
|
40
49
|
// SaveTitle passage takes precedence
|
|
41
50
|
if (saveTitlePassageContent) {
|
|
42
51
|
try {
|
package/src/story-api.ts
CHANGED
|
@@ -23,6 +23,7 @@ import { getBackendType } from './saves/storage';
|
|
|
23
23
|
import { registerClass } from './class-registry';
|
|
24
24
|
import {
|
|
25
25
|
frozenCopy,
|
|
26
|
+
readOnlyValue,
|
|
26
27
|
getActiveMutationScope,
|
|
27
28
|
mutateState,
|
|
28
29
|
} from './execute-mutation';
|
|
@@ -286,7 +287,7 @@ function createStoryAPI(): StoryAPI {
|
|
|
286
287
|
const value = key.includes('.')
|
|
287
288
|
? getByPath(namespace, key.split('.'))
|
|
288
289
|
: ownValue(namespace, key);
|
|
289
|
-
return scope ? frozenCopy(value) : value;
|
|
290
|
+
return scope ? frozenCopy(value) : readOnlyValue(value);
|
|
290
291
|
},
|
|
291
292
|
|
|
292
293
|
set(nameOrVars: string | Record<string, unknown>, value?: unknown): void {
|
package/src/story-init.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { h, render } from 'preact';
|
|
2
2
|
import { useStoryStore, recordStoryInitState, missingPassage } from './store';
|
|
3
3
|
import { parseMarkup } from './markup/parse';
|
|
4
|
+
import { registerWidgetDefinitions } from './widgets/register-widget-def';
|
|
4
5
|
import { renderNodes } from './markup/render';
|
|
5
6
|
import { setSaveTitlePassage } from './saves/save-manager';
|
|
6
7
|
import { emit } from './event-emitter';
|
|
@@ -35,6 +36,9 @@ export function executeStoryInit() {
|
|
|
35
36
|
const storyInit = state.storyData.passages.get('StoryInit');
|
|
36
37
|
if (storyInit) {
|
|
37
38
|
const ast = parseMarkup(storyInit.content);
|
|
39
|
+
// Its own widgets are usable from the passage itself: the macros render
|
|
40
|
+
// before the {widget} definitions' effects run.
|
|
41
|
+
registerWidgetDefinitions(ast, 'StoryInit');
|
|
38
42
|
|
|
39
43
|
// Mount into a persistent hidden container. It stays mounted until the
|
|
40
44
|
// next execution (restart) — this lets async effects (useEffect,
|
package/src/story-variables.ts
CHANGED
|
@@ -32,7 +32,13 @@ function tokensOf(text: string, textMode: boolean): Token[] {
|
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
export type VarType =
|
|
35
|
+
export type VarType =
|
|
36
|
+
| 'number'
|
|
37
|
+
| 'string'
|
|
38
|
+
| 'boolean'
|
|
39
|
+
| 'array'
|
|
40
|
+
| 'object'
|
|
41
|
+
| 'null';
|
|
36
42
|
|
|
37
43
|
export interface FieldSchema {
|
|
38
44
|
type: VarType;
|
|
@@ -66,7 +72,9 @@ function inferSchema(value: unknown): FieldSchema {
|
|
|
66
72
|
if (Array.isArray(value)) {
|
|
67
73
|
return { type: 'array' };
|
|
68
74
|
}
|
|
69
|
-
|
|
75
|
+
// A default of null means "nothing yet"; the value may later be anything
|
|
76
|
+
if (value === null) return { type: 'null' };
|
|
77
|
+
if (typeof value === 'object') {
|
|
70
78
|
const fields = new Map<string, FieldSchema>();
|
|
71
79
|
for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
|
|
72
80
|
fields.set(key, inferSchema(val));
|
|
@@ -156,6 +164,8 @@ function validateRef(
|
|
|
156
164
|
// Arrays have built-in methods/properties (push, find, length, etc.)
|
|
157
165
|
// so any field access on an array is allowed.
|
|
158
166
|
if (current.type === 'array') return null;
|
|
167
|
+
// A null default may later hold a value of any shape
|
|
168
|
+
if (current.type === 'null') return null;
|
|
159
169
|
|
|
160
170
|
// Primitives expose their wrapper's built-ins (length, toUpperCase,
|
|
161
171
|
// toFixed, etc.). Keep validating past properties of primitive type.
|