@rohal12/spindle 0.59.11 → 0.59.12
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 +7207 -7131
- package/package.json +1 -1
- package/src/components/macros/Computed.tsx +3 -9
- package/src/components/macros/For.tsx +34 -1
- 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/hooks/use-modal-focus.ts +45 -3
- package/src/index.tsx +9 -22
- package/src/markup/render.tsx +15 -1
- package/src/saves/save-manager.ts +9 -0
- package/src/story-init.ts +4 -0
- package/src/utils/control-edits.ts +15 -0
- package/src/widgets/register-widget-def.ts +44 -0
package/package.json
CHANGED
|
@@ -93,8 +93,6 @@ defineMacro({
|
|
|
93
93
|
{ name: 'expression', type: 'expression', required: true },
|
|
94
94
|
],
|
|
95
95
|
render({ rawArgs }, ctx) {
|
|
96
|
-
const [mergedVars, mergedTemps, mergedLocals, mergedTrans] = ctx.merged!;
|
|
97
|
-
|
|
98
96
|
let target: string;
|
|
99
97
|
let expr: string;
|
|
100
98
|
try {
|
|
@@ -133,13 +131,9 @@ defineMacro({
|
|
|
133
131
|
}
|
|
134
132
|
|
|
135
133
|
// The merged values only decide when to recompute; computeAndApply reads
|
|
136
|
-
// the current ones.
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
mergedTemps,
|
|
140
|
-
mergedLocals,
|
|
141
|
-
mergedTrans,
|
|
142
|
-
]);
|
|
134
|
+
// the current ones. The tuple is renewed with them and with the render
|
|
135
|
+
// counts, which rendered() / hasRendered() read.
|
|
136
|
+
ctx.hooks.useLayoutEffect(compute, [ctx.merged]);
|
|
143
137
|
|
|
144
138
|
return null;
|
|
145
139
|
},
|
|
@@ -3,6 +3,7 @@ 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 } from '../../utils/control-edits';
|
|
6
7
|
import type { ASTNode } from '../../markup/ast';
|
|
7
8
|
import { checkVariableName } from '../../utils/namespace';
|
|
8
9
|
import { LocalsScope } from './locals-scope';
|
|
@@ -76,6 +77,14 @@ function ForIteration({
|
|
|
76
77
|
);
|
|
77
78
|
}
|
|
78
79
|
|
|
80
|
+
/** What {for} remembers of its iterations between renders. */
|
|
81
|
+
interface IterationKeys {
|
|
82
|
+
contents: string[];
|
|
83
|
+
generations: number[];
|
|
84
|
+
next: number;
|
|
85
|
+
edits: number;
|
|
86
|
+
}
|
|
87
|
+
|
|
79
88
|
defineMacro({
|
|
80
89
|
name: 'for',
|
|
81
90
|
block: true,
|
|
@@ -88,6 +97,12 @@ defineMacro({
|
|
|
88
97
|
],
|
|
89
98
|
render({ rawArgs, children = [] }, ctx) {
|
|
90
99
|
const parentValues = useContext(LocalsValuesContext);
|
|
100
|
+
const keys = ctx.hooks.useRef<IterationKeys>({
|
|
101
|
+
contents: [],
|
|
102
|
+
generations: [],
|
|
103
|
+
next: 0,
|
|
104
|
+
edits: controlEditVersion(),
|
|
105
|
+
}).current;
|
|
91
106
|
|
|
92
107
|
let loop: ReturnType<typeof loopArgs>;
|
|
93
108
|
let list: unknown[];
|
|
@@ -112,9 +127,27 @@ defineMacro({
|
|
|
112
127
|
}
|
|
113
128
|
|
|
114
129
|
const { itemVar, indexVar } = loop;
|
|
130
|
+
// An iteration is remounted when its item's contents change, so mount-only
|
|
131
|
+
// macros run again for the new item (#45) -- unless the change is a
|
|
132
|
+
// reader editing the item through a control: remounting would drop the
|
|
133
|
+
// control's focus after the first keystroke.
|
|
134
|
+
const edited = controlEditVersion() !== keys.edits;
|
|
135
|
+
keys.edits = controlEditVersion();
|
|
136
|
+
const generations = list.map((item, i) => {
|
|
137
|
+
const contents = stableKey(item);
|
|
138
|
+
if (
|
|
139
|
+
keys.generations[i] === undefined ||
|
|
140
|
+
(contents !== keys.contents[i] && !edited)
|
|
141
|
+
) {
|
|
142
|
+
keys.generations[i] = keys.next++;
|
|
143
|
+
}
|
|
144
|
+
keys.contents[i] = contents;
|
|
145
|
+
return keys.generations[i]!;
|
|
146
|
+
});
|
|
147
|
+
keys.contents.length = keys.generations.length = list.length;
|
|
115
148
|
const content = list.map((item, i) => (
|
|
116
149
|
<ForIteration
|
|
117
|
-
key={`${i}-${
|
|
150
|
+
key={`${i}-${generations[i]}`}
|
|
118
151
|
parentValues={parentValues}
|
|
119
152
|
itemVar={itemVar}
|
|
120
153
|
itemValue={item}
|
|
@@ -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();
|
|
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.
|
|
@@ -10,7 +10,7 @@ const FOCUSABLE = [
|
|
|
10
10
|
'textarea:not([disabled])',
|
|
11
11
|
'iframe',
|
|
12
12
|
'[contenteditable="true"]',
|
|
13
|
-
'[tabindex]
|
|
13
|
+
'[tabindex]',
|
|
14
14
|
].join(',');
|
|
15
15
|
|
|
16
16
|
/**
|
|
@@ -27,15 +27,57 @@ function isRendered(el: HTMLElement): boolean {
|
|
|
27
27
|
return true;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
/** The `tabindex` of `el` as an integer, or null when absent or invalid. */
|
|
31
|
+
function tabIndexAttr(el: HTMLElement): number | null {
|
|
32
|
+
const raw = el.getAttribute('tabindex');
|
|
33
|
+
if (raw === null || !/^\s*[+-]?\d+\s*$/.test(raw)) return null;
|
|
34
|
+
return parseInt(raw, 10);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Whether the tab key can reach `el`: a negative tabindex only allows
|
|
39
|
+
* scripted focus, and of a radio group the browser tabs to the checked
|
|
40
|
+
* member only (the first one when none is checked).
|
|
41
|
+
*/
|
|
42
|
+
function isTabStop(el: HTMLElement): boolean {
|
|
43
|
+
const tabIndex = tabIndexAttr(el);
|
|
44
|
+
if (tabIndex !== null && tabIndex < 0) return false;
|
|
45
|
+
if (el instanceof HTMLInputElement && el.type === 'radio' && el.name) {
|
|
46
|
+
const group = Array.from(
|
|
47
|
+
(
|
|
48
|
+
el.form ?? (el.getRootNode() as ParentNode)
|
|
49
|
+
).querySelectorAll<HTMLInputElement>('input[type="radio"]'),
|
|
50
|
+
).filter((r) => r.name === el.name && r.form === el.form && !r.disabled);
|
|
51
|
+
const stop = group.find((r) => r.checked) ?? group[0];
|
|
52
|
+
return el === stop;
|
|
53
|
+
}
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The elements of `root` in the order Tab visits them: positive tabindexes
|
|
59
|
+
* first, ascending, then the others in document order.
|
|
60
|
+
*/
|
|
30
61
|
function focusables(root: HTMLElement): HTMLElement[] {
|
|
31
|
-
|
|
62
|
+
const stops = Array.from(
|
|
63
|
+
root.querySelectorAll<HTMLElement>(FOCUSABLE),
|
|
64
|
+
).filter(
|
|
32
65
|
(el) =>
|
|
33
66
|
!el.hidden &&
|
|
34
67
|
!el.closest('[hidden], [inert]') &&
|
|
35
68
|
// Also covers controls disabled by a <fieldset disabled> ancestor
|
|
36
69
|
!el.matches(':disabled') &&
|
|
37
|
-
isRendered(el)
|
|
70
|
+
isRendered(el) &&
|
|
71
|
+
isTabStop(el),
|
|
38
72
|
);
|
|
73
|
+
const positive = (el: HTMLElement) => Math.max(tabIndexAttr(el) ?? 0, 0);
|
|
74
|
+
// Array.prototype.sort is stable: equal tabindexes keep document order
|
|
75
|
+
return [
|
|
76
|
+
...stops
|
|
77
|
+
.filter((el) => positive(el) > 0)
|
|
78
|
+
.sort((a, b) => positive(a) - positive(b)),
|
|
79
|
+
...stops.filter((el) => positive(el) === 0),
|
|
80
|
+
];
|
|
39
81
|
}
|
|
40
82
|
|
|
41
83
|
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
|
@@ -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',
|
|
@@ -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-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,
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Counts the edits a reader made through a control bound to a story
|
|
3
|
+
* variable (`{textbox}`, `{checkbox}`, ...). {for} tells these apart from a
|
|
4
|
+
* list that is replaced: typing into a control inside an iteration changes
|
|
5
|
+
* its item, but must not remount the iteration and drop the control's focus.
|
|
6
|
+
*/
|
|
7
|
+
let version = 0;
|
|
8
|
+
|
|
9
|
+
export function noteControlEdit(): void {
|
|
10
|
+
version++;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function controlEditVersion(): number {
|
|
14
|
+
return version;
|
|
15
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { registerWidget } from './widget-registry';
|
|
2
|
+
import { astContainsChildren } from './ast-scanner';
|
|
3
|
+
import { registerBlockMacro, type ASTNode } from '../markup/ast';
|
|
4
|
+
import { errorMessage } from '../utils/error-message';
|
|
5
|
+
import { parseWidgetDef, type WidgetDef } from './widget-def';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Register the widget a definition declares, with its body. Widgets whose
|
|
9
|
+
* body renders {@children} take a closing tag, so they are registered as
|
|
10
|
+
* block macros too: passages parsed later nest their content. A parameter
|
|
11
|
+
* that no namespace can hold throws (see registerWidget), registering
|
|
12
|
+
* nothing.
|
|
13
|
+
*/
|
|
14
|
+
export function registerWidgetDef(
|
|
15
|
+
{ name, params }: WidgetDef,
|
|
16
|
+
body: ASTNode[],
|
|
17
|
+
): void {
|
|
18
|
+
const isBlock = astContainsChildren(body);
|
|
19
|
+
registerWidget(name, body, params, isBlock);
|
|
20
|
+
if (isBlock) registerBlockMacro(name);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Register the widgets `nodes` (a passage's markup) define at its top level,
|
|
25
|
+
* as the {widget} macro does when it renders. A widget that fails to register
|
|
26
|
+
* is reported; the others still register.
|
|
27
|
+
*/
|
|
28
|
+
export function registerWidgetDefinitions(
|
|
29
|
+
nodes: ASTNode[],
|
|
30
|
+
passageName: string,
|
|
31
|
+
): void {
|
|
32
|
+
for (const node of nodes) {
|
|
33
|
+
if (node.type === 'macro' && node.name === 'widget' && node.rawArgs) {
|
|
34
|
+
const def = parseWidgetDef(node.rawArgs);
|
|
35
|
+
try {
|
|
36
|
+
registerWidgetDef(def, node.children as ASTNode[]);
|
|
37
|
+
} catch (err) {
|
|
38
|
+
console.error(
|
|
39
|
+
`spindle: widget "${def.name}" in passage "${passageName}" was not registered: ${errorMessage(err)}`,
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|