@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rohal12/spindle",
3
- "version": "0.59.11",
3
+ "version": "0.59.12",
4
4
  "type": "module",
5
5
  "description": "A Preact-based story format for Twine 2.",
6
6
  "license": "Unlicense",
@@ -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
- ctx.hooks.useLayoutEffect(compute, [
138
- mergedVars,
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}-${stableKey(item)}`}
150
+ key={`${i}-${generations[i]}`}
118
151
  parentValues={parentValues}
119
152
  itemVar={itemVar}
120
153
  itemValue={item}
@@ -1,6 +1,4 @@
1
- import { registerWidget } from '../../widgets/widget-registry';
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
- // Parameterized widgets always get their own local scope, even when invoked
50
- // without arguments: missing parameters shadow outer locals as undefined.
51
- if (params.length === 0) {
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
 
@@ -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]:not([tabindex="-1"])',
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
- return Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE)).filter(
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 { registerWidgetDef } from './components/macros/Widget';
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.
@@ -348,7 +348,11 @@ function convertDomNode(
348
348
  return null;
349
349
  }
350
350
 
351
- /** Inline elements where block-level markdown (lists, headings) is invalid. */
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
+ }