@rohal12/spindle 0.59.16 → 0.59.18

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.
@@ -151,7 +151,11 @@ function lexeme(lex, loc, fields) {
151
151
  /** The AST node of a lexeme that stands alone (not a macro or element). */
152
152
  function nodeOf(x) {
153
153
  switch (x.lex) {
154
- case "text": return {
154
+ case "text": return x.comment ? {
155
+ type: "text",
156
+ value: x.value,
157
+ comment: true
158
+ } : {
155
159
  type: "text",
156
160
  value: x.value
157
161
  };
@@ -223,7 +227,7 @@ function tokenOf(x) {
223
227
  }
224
228
  /**
225
229
  * Flatten items (lexemes, nodes, arrays of them, nulls) and join adjacent
226
- * text, except escaped braces, which stay a text item of their own.
230
+ * text, except escaped braces and comments, which stay a text item of their own.
227
231
  */
228
232
  function joinText(items, convert) {
229
233
  const out = [];
@@ -231,7 +235,7 @@ function joinText(items, convert) {
231
235
  const add = (item) => {
232
236
  if (item === null || item === void 0) return;
233
237
  if (Array.isArray(item)) return item.forEach(add);
234
- if (item.lex === "text" && !item.escape && last && last.lex === "text" && !last.escape && last.end === item.start) {
238
+ if (item.lex === "text" && !item.escape && !item.comment && last && last.lex === "text" && !last.escape && !last.comment && last.end === item.start) {
235
239
  last.value += item.value;
236
240
  last.end = item.end;
237
241
  return;
@@ -725,7 +729,9 @@ function peg$parse(input, options) {
725
729
  return lexeme("text", location(), { value });
726
730
  }
727
731
  function peg$f41(value) {
728
- return lexeme("text", location(), { value });
732
+ const node = lexeme("text", location(), { value });
733
+ node.comment = true;
734
+ return node;
729
735
  }
730
736
  function peg$f42(bs) {
731
737
  return bs.length % 2 === 1;
@@ -10586,70 +10592,6 @@ function evaluatePassageName(expr, evaluate, { storyData, currentPassage }) {
10586
10592
  if (storyData && !storyData.passages.has(name)) throw noPassageError(name, currentPassage);
10587
10593
  return name;
10588
10594
  }
10589
- //#endregion
10590
- //#region src/widgets/widget-def.ts
10591
- /**
10592
- * Reading widget definitions without rendering anything: the `{widget}`
10593
- * macro, startup and markup validation (also in tooling) share these.
10594
- */
10595
- /** A {widget} definition's name, then its `@` parameters. */
10596
- var WIDGET_PARAMETERS = [{
10597
- name: "name",
10598
- type: "text",
10599
- holds: "text",
10600
- required: true
10601
- }, {
10602
- name: "parameters",
10603
- type: "text",
10604
- holds: "text"
10605
- }];
10606
- /**
10607
- * The widget a {widget} definition's arguments declare: its name and its
10608
- * parameters, the words after it that start with `@` (docs/widgets.md).
10609
- * Other words, such as `$name`, are not parameters.
10610
- */
10611
- function widgetDef({ name = "", parameters = "" }) {
10612
- return {
10613
- name,
10614
- params: parameters.split(/\s+/).filter((word) => word.startsWith("@"))
10615
- };
10616
- }
10617
- /** Read the arguments of a {widget} definition (see widgetDef). */
10618
- function parseWidgetDef(rawArgs) {
10619
- return widgetDef(parseMacroArgs(rawArgs, WIDGET_PARAMETERS));
10620
- }
10621
- /**
10622
- * The names of the block widgets (those whose body renders `{@children}`)
10623
- * that StoryInit and the passages tagged `widget` define. They must be
10624
- * known as block macros before any passage is parsed, so that passages
10625
- * invoking them nest their content whatever the passage order.
10626
- *
10627
- * The passages are read as flat tokens, which need no block macros to be
10628
- * known, so a `{@children}` in an HTML comment or in a `{do}` body is text
10629
- * here as it is to the parser, and does not make a block widget (#387).
10630
- */
10631
- function blockWidgetNames(passages) {
10632
- const names = [];
10633
- for (const passage of passages) {
10634
- if (passage.name !== "StoryInit" && !passage.tags?.includes("widget")) continue;
10635
- const open = [];
10636
- for (const token of tokenizeMarkupTolerant(passage.content).tokens) if (token.type === "macro" && token.name.toLowerCase() === "widget") {
10637
- if (!token.isClose) {
10638
- open.push({
10639
- rawArgs: token.rawArgs,
10640
- isBlock: false
10641
- });
10642
- continue;
10643
- }
10644
- const def = open.pop();
10645
- if (!def?.isBlock) continue;
10646
- try {
10647
- names.push(parseWidgetDef(def.rawArgs).name);
10648
- } catch {}
10649
- } else if (token.type === "variable" && token.scope === "local" && token.name === "children" && open.length > 0) open[open.length - 1].isBlock = true;
10650
- }
10651
- return names;
10652
- }
10653
10595
  /** The parameters of the built-in sub-macros that take arguments. */
10654
10596
  var SUB_MACRO_PARAMETERS = new Map([["option", [{
10655
10597
  name: "value",
@@ -10983,6 +10925,214 @@ function collectPassageReferences(src, tokens, parametersOf) {
10983
10925
  });
10984
10926
  return refs;
10985
10927
  }
10928
+ new NameMap();
10929
+ new NameMap();
10930
+ new NameSet();
10931
+ /**
10932
+ * How a macro argument is read (see components/macros/macro-args.ts). Every
10933
+ * declared parameter names one: there is no default.
10934
+ * Quoted strings accept `\"`, `\'` and `\\` escapes.
10935
+ * - `expression`: code, as written.
10936
+ * - `statements`: code run as statements (`{set}`), as written.
10937
+ * - `passage`: a passage name: a quoted string or an expression, as written.
10938
+ * - `variable`: a variable reference such as `$name` or `"$name"`, as written.
10939
+ * - `string`: one quoted string; anything else leaves the argument unset.
10940
+ * - `text`: one quoted string, or text with any loose quotes stripped.
10941
+ * - `names`: a comma-separated list of names (`@item, @i`).
10942
+ * - `delay`: a duration (`2s`, `500ms`, `300`) in milliseconds.
10943
+ * - `number`: a number.
10944
+ * - `flag`: a keyword, the parameter's name, at the start or end; a boolean.
10945
+ * - `separator`: a word (`of`) or `=` separating the parameters before it
10946
+ * from those after it; a boolean.
10947
+ * - `options`: keywords, the names of its `parameters`, each followed by a
10948
+ * quoted string or a number unless it is a flag.
10949
+ */
10950
+ var PARAMETER_TYPES = [
10951
+ "expression",
10952
+ "statements",
10953
+ "passage",
10954
+ "variable",
10955
+ "string",
10956
+ "text",
10957
+ "names",
10958
+ "delay",
10959
+ "number",
10960
+ "flag",
10961
+ "separator",
10962
+ "options"
10963
+ ];
10964
+ /**
10965
+ * What the value of a `string` or `text` argument holds, for the check at
10966
+ * story start and for tooling (see code-check.ts):
10967
+ * - `markup`: markup the macro renders (`{button}`'s label): its markup
10968
+ * is checked.
10969
+ * - `text`: plain text the macro uses as written (`{checkbox}`'s label).
10970
+ * - `passage`: a passage name (`{watch}`'s `goto`): the passage must exist.
10971
+ * - `expression`, `statements`: code (`{watch}`'s condition and `run`): it
10972
+ * is checked as code, and its variable references against the schema.
10973
+ *
10974
+ * Without it, the argument of a macro with `interpolate` holds `markup`,
10975
+ * any other `text`.
10976
+ */
10977
+ var STRING_HOLDS = [
10978
+ "markup",
10979
+ "text",
10980
+ "passage",
10981
+ "expression",
10982
+ "statements"
10983
+ ];
10984
+ /**
10985
+ * Throw if a parameter `macro` declares (or an option of one) has no type,
10986
+ * or one that isn't a ParameterType: arguments are read by their type, and
10987
+ * there is no default. Throw too if it declares what it `holds` wrongly.
10988
+ */
10989
+ function checkParameterTypes(macro, parameters) {
10990
+ const holdsFix = `Give a \`string\` or \`text\` parameter one of ${STRING_HOLDS.join(", ")} to hold (see docs/custom-macros.md#what-a-string-holds).`;
10991
+ for (const param of parameters) {
10992
+ const type = param.type;
10993
+ if (!PARAMETER_TYPES.includes(type)) throw parameterError(macro, param, type === void 0 ? "has no type" : `has the unknown type ${JSON.stringify(type)}`, `Give it one of the types ${PARAMETER_TYPES.join(", ")} (see docs/custom-macros.md#parameter-types), or declare no parameters and read props.rawArgs.`);
10994
+ const holds = param.holds;
10995
+ if (holds !== void 0 && type !== "string" && type !== "text") throw parameterError(macro, param, `is of the type ${type}, which holds what it is`, holdsFix);
10996
+ if (holds !== void 0 && !STRING_HOLDS.includes(holds)) throw parameterError(macro, param, `holds the unknown ${JSON.stringify(holds)}`, holdsFix);
10997
+ if (param.parameters) checkParameterTypes(macro, param.parameters);
10998
+ }
10999
+ }
11000
+ /** The error for the parameter `param` of `macro`: what is wrong, and the fix. */
11001
+ function parameterError(macro, param, problem, fix) {
11002
+ return /* @__PURE__ */ new Error(`spindle: The parameter "${param.name}" of the macro {${macro}} ${problem}. ${fix}`);
11003
+ }
11004
+ var metadataRegistry = new NameMap();
11005
+ function getMacroRegistry() {
11006
+ return Array.from(metadataRegistry.values());
11007
+ }
11008
+ //#endregion
11009
+ //#region src/widgets/ast-scanner.ts
11010
+ /** The declared parameters of the macros registered so far. */
11011
+ var registeredParameters = (name) => parameterLookup(getMacroRegistry())(name);
11012
+ /**
11013
+ * Whether the text of an attribute value or macro label renders a
11014
+ * `{@children}`: it is markup, parsed as the renderer parses it.
11015
+ */
11016
+ function textContainsChildren(text, parametersOf) {
11017
+ if (!text.includes("{")) return false;
11018
+ let nodes;
11019
+ try {
11020
+ nodes = parseMarkup(text, { text: true });
11021
+ } catch {
11022
+ return false;
11023
+ }
11024
+ return astContainsChildren(nodes, parametersOf);
11025
+ }
11026
+ /**
11027
+ * Whether a tag renders a `{@children}` in an attribute value or a macro
11028
+ * argument that holds markup (`<span title="{@children}">`,
11029
+ * `{button "{@children}"}`). Literal arguments and code are not markup.
11030
+ */
11031
+ function tokenTextContainsChildren(token, parametersOf = registeredParameters) {
11032
+ for (const piece of codeAndText("", [token], parametersOf)) if (piece.kind === "text" && textContainsChildren(piece.text, parametersOf)) return true;
11033
+ return false;
11034
+ }
11035
+ /**
11036
+ * Recursively scan an AST node array for a VariableNode
11037
+ * with scope 'local' and name 'children' (@children), in the content and in
11038
+ * the attributes and labels that render markup.
11039
+ */
11040
+ function astContainsChildren(nodes, parametersOf = registeredParameters) {
11041
+ for (const node of nodes) {
11042
+ if (node.type === "variable" && node.scope === "local" && node.name === "children") return true;
11043
+ if (node.type === "html") {
11044
+ const span = {
11045
+ start: 0,
11046
+ end: 0
11047
+ };
11048
+ if (tokenTextContainsChildren({
11049
+ isClose: false,
11050
+ isSelfClose: false,
11051
+ ...node,
11052
+ ...span
11053
+ }, parametersOf) || astContainsChildren(node.children, parametersOf)) return true;
11054
+ }
11055
+ if (node.type === "macro") {
11056
+ const span = {
11057
+ start: 0,
11058
+ end: 0
11059
+ };
11060
+ if (tokenTextContainsChildren({
11061
+ isClose: false,
11062
+ ...node,
11063
+ ...span
11064
+ }, parametersOf) || astContainsChildren(node.children, parametersOf)) return true;
11065
+ if (node.branches) {
11066
+ for (const branch of node.branches) if (astContainsChildren(branch.children, parametersOf)) return true;
11067
+ }
11068
+ }
11069
+ }
11070
+ return false;
11071
+ }
11072
+ //#endregion
11073
+ //#region src/widgets/widget-def.ts
11074
+ /**
11075
+ * Reading widget definitions without rendering anything: the `{widget}`
11076
+ * macro, startup and markup validation (also in tooling) share these.
11077
+ */
11078
+ /** A {widget} definition's name, then its `@` parameters. */
11079
+ var WIDGET_PARAMETERS = [{
11080
+ name: "name",
11081
+ type: "text",
11082
+ holds: "text",
11083
+ required: true
11084
+ }, {
11085
+ name: "parameters",
11086
+ type: "text",
11087
+ holds: "text"
11088
+ }];
11089
+ /**
11090
+ * The widget a {widget} definition's arguments declare: its name and its
11091
+ * parameters, the words after it that start with `@` (docs/widgets.md).
11092
+ * Other words, such as `$name`, are not parameters.
11093
+ */
11094
+ function widgetDef({ name = "", parameters = "" }) {
11095
+ return {
11096
+ name,
11097
+ params: parameters.split(/\s+/).filter((word) => word.startsWith("@"))
11098
+ };
11099
+ }
11100
+ /** Read the arguments of a {widget} definition (see widgetDef). */
11101
+ function parseWidgetDef(rawArgs) {
11102
+ return widgetDef(parseMacroArgs(rawArgs, WIDGET_PARAMETERS));
11103
+ }
11104
+ /**
11105
+ * The names of the block widgets (those whose body renders `{@children}`)
11106
+ * that StoryInit and the passages tagged `widget` define. They must be
11107
+ * known as block macros before any passage is parsed, so that passages
11108
+ * invoking them nest their content whatever the passage order.
11109
+ *
11110
+ * The passages are read as flat tokens, which need no block macros to be
11111
+ * known, so a `{@children}` in an HTML comment or in a `{do}` body is text
11112
+ * here as it is to the parser, and does not make a block widget (#387).
11113
+ */
11114
+ function blockWidgetNames(passages, parametersOf = registeredParameters) {
11115
+ const names = [];
11116
+ for (const passage of passages) {
11117
+ if (passage.name !== "StoryInit" && !passage.tags?.includes("widget")) continue;
11118
+ const open = [];
11119
+ for (const token of tokenizeMarkupTolerant(passage.content).tokens) if (token.type === "macro" && token.name.toLowerCase() === "widget") {
11120
+ if (!token.isClose) {
11121
+ open.push({
11122
+ rawArgs: token.rawArgs,
11123
+ isBlock: false
11124
+ });
11125
+ continue;
11126
+ }
11127
+ const def = open.pop();
11128
+ if (!def?.isBlock) continue;
11129
+ try {
11130
+ names.push(parseWidgetDef(def.rawArgs).name);
11131
+ } catch {}
11132
+ } else if (open.length > 0 && (token.type === "variable" && token.scope === "local" && token.name === "children" || tokenTextContainsChildren(token, parametersOf))) open[open.length - 1].isBlock = true;
11133
+ }
11134
+ return names;
11135
+ }
10986
11136
  //#endregion
10987
11137
  //#region src/markup/validate.ts
10988
11138
  /**
@@ -11157,83 +11307,6 @@ function validateMarkup(passages, options) {
11157
11307
  for (const [passage, tokens] of parsed) withParseCache(() => checkTokens(passage, passage.content, tokens, 0, ""));
11158
11308
  return diagnostics;
11159
11309
  }
11160
- new NameMap();
11161
- new NameMap();
11162
- new NameSet();
11163
- /**
11164
- * How a macro argument is read (see components/macros/macro-args.ts). Every
11165
- * declared parameter names one: there is no default.
11166
- * Quoted strings accept `\"`, `\'` and `\\` escapes.
11167
- * - `expression`: code, as written.
11168
- * - `statements`: code run as statements (`{set}`), as written.
11169
- * - `passage`: a passage name: a quoted string or an expression, as written.
11170
- * - `variable`: a variable reference such as `$name` or `"$name"`, as written.
11171
- * - `string`: one quoted string; anything else leaves the argument unset.
11172
- * - `text`: one quoted string, or text with any loose quotes stripped.
11173
- * - `names`: a comma-separated list of names (`@item, @i`).
11174
- * - `delay`: a duration (`2s`, `500ms`, `300`) in milliseconds.
11175
- * - `number`: a number.
11176
- * - `flag`: a keyword, the parameter's name, at the start or end; a boolean.
11177
- * - `separator`: a word (`of`) or `=` separating the parameters before it
11178
- * from those after it; a boolean.
11179
- * - `options`: keywords, the names of its `parameters`, each followed by a
11180
- * quoted string or a number unless it is a flag.
11181
- */
11182
- var PARAMETER_TYPES = [
11183
- "expression",
11184
- "statements",
11185
- "passage",
11186
- "variable",
11187
- "string",
11188
- "text",
11189
- "names",
11190
- "delay",
11191
- "number",
11192
- "flag",
11193
- "separator",
11194
- "options"
11195
- ];
11196
- /**
11197
- * What the value of a `string` or `text` argument holds, for the check at
11198
- * story start and for tooling (see code-check.ts):
11199
- * - `markup`: markup the macro renders (`{button}`'s label): its markup
11200
- * is checked.
11201
- * - `text`: plain text the macro uses as written (`{checkbox}`'s label).
11202
- * - `passage`: a passage name (`{watch}`'s `goto`): the passage must exist.
11203
- * - `expression`, `statements`: code (`{watch}`'s condition and `run`): it
11204
- * is checked as code, and its variable references against the schema.
11205
- *
11206
- * Without it, the argument of a macro with `interpolate` holds `markup`,
11207
- * any other `text`.
11208
- */
11209
- var STRING_HOLDS = [
11210
- "markup",
11211
- "text",
11212
- "passage",
11213
- "expression",
11214
- "statements"
11215
- ];
11216
- /**
11217
- * Throw if a parameter `macro` declares (or an option of one) has no type,
11218
- * or one that isn't a ParameterType: arguments are read by their type, and
11219
- * there is no default. Throw too if it declares what it `holds` wrongly.
11220
- */
11221
- function checkParameterTypes(macro, parameters) {
11222
- const holdsFix = `Give a \`string\` or \`text\` parameter one of ${STRING_HOLDS.join(", ")} to hold (see docs/custom-macros.md#what-a-string-holds).`;
11223
- for (const param of parameters) {
11224
- const type = param.type;
11225
- if (!PARAMETER_TYPES.includes(type)) throw parameterError(macro, param, type === void 0 ? "has no type" : `has the unknown type ${JSON.stringify(type)}`, `Give it one of the types ${PARAMETER_TYPES.join(", ")} (see docs/custom-macros.md#parameter-types), or declare no parameters and read props.rawArgs.`);
11226
- const holds = param.holds;
11227
- if (holds !== void 0 && type !== "string" && type !== "text") throw parameterError(macro, param, `is of the type ${type}, which holds what it is`, holdsFix);
11228
- if (holds !== void 0 && !STRING_HOLDS.includes(holds)) throw parameterError(macro, param, `holds the unknown ${JSON.stringify(holds)}`, holdsFix);
11229
- if (param.parameters) checkParameterTypes(macro, param.parameters);
11230
- }
11231
- }
11232
- /** The error for the parameter `param` of `macro`: what is wrong, and the fix. */
11233
- function parameterError(macro, param, problem, fix) {
11234
- return /* @__PURE__ */ new Error(`spindle: The parameter "${param.name}" of the macro {${macro}} ${problem}. ${fix}`);
11235
- }
11236
- new NameMap();
11237
11310
  //#endregion
11238
11311
  //#region src/utils/namespace.ts
11239
11312
  /** The variable name no namespace can hold. */
@@ -11430,7 +11503,8 @@ function validateStoryMarkup(passages, macros, options = {}) {
11430
11503
  const list = [...passages];
11431
11504
  const all = [...macros];
11432
11505
  const known = /* @__PURE__ */ new Set();
11433
- const blocks = new Set(blockWidgetNames(list).map((n) => n.toLowerCase()));
11506
+ const parametersOf = parameterLookup(all);
11507
+ const blocks = new Set(blockWidgetNames(list, parametersOf).map((n) => n.toLowerCase()));
11434
11508
  for (const macro of all) {
11435
11509
  const name = macro.name.toLowerCase();
11436
11510
  known.add(name);
@@ -11440,7 +11514,7 @@ function validateStoryMarkup(passages, macros, options = {}) {
11440
11514
  return validateMarkup(list, {
11441
11515
  isKnownMacro: (name) => known.has(name),
11442
11516
  macroNames: known,
11443
- parametersOf: parameterLookup(all),
11517
+ parametersOf,
11444
11518
  isBlockMacro: (name) => blocks.has(name.toLowerCase()) || isBlockMacro(name),
11445
11519
  checkPassageNames: options.checkPassageNames
11446
11520
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rohal12/spindle",
3
- "version": "0.59.16",
3
+ "version": "0.59.18",
4
4
  "type": "module",
5
5
  "description": "A Preact-based story format for Twine 2.",
6
6
  "license": "Unlicense",
@@ -1,7 +1,12 @@
1
- import { useMemo } from 'preact/hooks';
1
+ import { useLayoutEffect, useMemo } from 'preact/hooks';
2
2
  import { useStoryFields } from '../hooks/use-story-fields';
3
3
  import { parseMarkup } from '../markup/parse';
4
- import { renderInlineNodes, NobrContext } from '../markup/render';
4
+ import {
5
+ renderInlineNodes,
6
+ InterfaceContext,
7
+ NobrContext,
8
+ } from '../markup/render';
9
+ import { declareInterfaceMounted } from '../triggers';
5
10
  import { errorMessage } from '../utils/error-message';
6
11
 
7
12
  const DEFAULT_MARKUP =
@@ -26,9 +31,15 @@ export function StoryInterface() {
26
31
  }
27
32
  }, [markup]);
28
33
 
29
- return nobr ? (
30
- <NobrContext.Provider value={true}>{rendered}</NobrContext.Provider>
31
- ) : (
32
- rendered
34
+ useLayoutEffect(declareInterfaceMounted, []);
35
+
36
+ return (
37
+ <InterfaceContext.Provider value={true}>
38
+ {nobr ? (
39
+ <NobrContext.Provider value={true}>{rendered}</NobrContext.Provider>
40
+ ) : (
41
+ rendered
42
+ )}
43
+ </InterfaceContext.Provider>
33
44
  );
34
45
  }
@@ -9,6 +9,7 @@ import { useStoryStore, type StoryState } from '../../store';
9
9
  import {
10
10
  useStoryFields,
11
11
  FrozenStateContext,
12
+ OutgoingContext,
12
13
  } from '../../hooks/use-story-fields';
13
14
  import { Passage, renderPassageContent } from '../Passage';
14
15
  import { defineMacro } from '../../define-macro';
@@ -140,6 +141,11 @@ defineMacro({
140
141
  (state: StoryState) => state.navigationId !== displayedId,
141
142
  [displayedId],
142
143
  );
144
+ // Read live: a timer asks it before the outgoing tree has unmounted
145
+ const hasLeft = useCallback(
146
+ () => isOutgoing(useStoryStore.getState()),
147
+ [isOutgoing],
148
+ );
143
149
  const lastElement = useRef<preact.VNode | null>(null);
144
150
 
145
151
  // Container ref for snapshot insertion
@@ -350,7 +356,9 @@ defineMacro({
350
356
  >
351
357
  {passageElement && (
352
358
  <FrozenStateContext.Provider value={isOutgoing}>
353
- {passageElement}
359
+ <OutgoingContext.Provider value={hasLeft}>
360
+ {passageElement}
361
+ </OutgoingContext.Provider>
354
362
  </FrozenStateContext.Provider>
355
363
  )}
356
364
  </div>
@@ -1,6 +1,7 @@
1
1
  import { createContext } from 'preact';
2
2
  import { defineMacro } from '../../define-macro';
3
3
  import { DELAY_PARAMETER } from './macro-args';
4
+ import { OutgoingContext } from '../../hooks/use-story-fields';
4
5
 
5
6
  export const RepeatContext = createContext<{ stop: () => void }>({
6
7
  stop: () => {},
@@ -12,17 +13,21 @@ defineMacro({
12
13
  interpolate: true,
13
14
  parameters: [DELAY_PARAMETER],
14
15
  render({ children = [] }, ctx) {
15
- const { useState, useEffect, useCallback } = ctx.hooks;
16
+ const { useState, useEffect, useCallback, useContext } = ctx.hooks;
16
17
 
17
18
  const delay = ctx.args.delay ?? 0;
18
19
  const [count, setCount] = useState(0);
19
20
  const [stopped, setStopped] = useState(false);
20
21
 
22
+ const hasLeft = useContext(OutgoingContext);
23
+
21
24
  const stop = useCallback(() => setStopped(true), []);
22
25
 
23
26
  useEffect(() => {
24
27
  if (stopped) return;
25
28
  const interval = setInterval(() => {
29
+ // The passage was left: its body must not run again (#410)
30
+ if (hasLeft?.()) return;
26
31
  setCount((c) => c + 1);
27
32
  }, delay);
28
33
  return () => clearInterval(interval);
@@ -1,6 +1,7 @@
1
1
  import { defineMacro } from '../../define-macro';
2
2
  import { DELAY_PARAMETER, parseMacroArgs } from './macro-args';
3
3
  import { wrapContent } from './display';
4
+ import { OutgoingContext } from '../../hooks/use-story-fields';
4
5
 
5
6
  /** The parameters of {timed} and of each {next}: the delay before it shows. */
6
7
  const TIMED_PARAMETERS = [DELAY_PARAMETER] as const;
@@ -11,7 +12,8 @@ defineMacro({
11
12
  interpolate: true,
12
13
  parameters: TIMED_PARAMETERS,
13
14
  render({ branches = [] }, ctx) {
14
- const { useState, useEffect, useMemo } = ctx.hooks;
15
+ const { useState, useEffect, useMemo, useContext } = ctx.hooks;
16
+ const hasLeft = useContext(OutgoingContext);
15
17
 
16
18
  const sections = useMemo(() => {
17
19
  return branches.map((branch) => ({
@@ -31,6 +33,8 @@ defineMacro({
31
33
  const delay = sections[nextIndex]!.delay;
32
34
 
33
35
  const timer = setTimeout(() => {
36
+ // The passage was left: its body must not run (#410)
37
+ if (hasLeft?.()) return;
34
38
  setVisibleIndex(nextIndex);
35
39
  }, delay);
36
40
 
@@ -1,8 +1,10 @@
1
1
  import { defineMacro } from '../../define-macro';
2
2
  import { FrozenStateContext } from '../../hooks/use-story-fields';
3
3
  import { useStoryStore } from '../../store';
4
+ import { InterfaceContext } from '../../markup/render';
4
5
  import {
5
6
  addMacroTrigger,
7
+ declareInterfaceWatch,
6
8
  removeTrigger,
7
9
  subscribeTriggerResets,
8
10
  triggerResets,
@@ -50,6 +52,15 @@ defineMacro({
50
52
  () => subscribeTriggerResets(() => rerender((n) => n + 1)),
51
53
  [],
52
54
  );
55
+ // The interface's watchers are re-registered by a load of a save made
56
+ // before the interface mounted (#419)
57
+ const inInterface = hooks.useContext(InterfaceContext);
58
+ const declaredKey = JSON.stringify([condition, options]);
59
+ hooks.useLayoutEffect(
60
+ () =>
61
+ inInterface ? declareInterfaceWatch(condition, options) : undefined,
62
+ [inInterface, declaredKey],
63
+ );
53
64
  const registered = hooks.useRef<number | null>(null);
54
65
  if (
55
66
  registered.current === null ||
@@ -10,12 +10,16 @@ import {
10
10
  } from '../../markup/render';
11
11
  import type { ASTNode } from '../../markup/ast';
12
12
  import { useRenderOptions } from '../../hooks/use-render-options';
13
- import { FrozenStateContext } from '../../hooks/use-story-fields';
13
+ import {
14
+ FrozenStateContext,
15
+ OutgoingContext,
16
+ } from '../../hooks/use-story-fields';
14
17
  import { liveLocalsView } from '../../utils/live-locals';
15
18
  import { runWithCommittedMutations } from '../../execute-mutation';
16
19
  import { subscribeStateReplacement } from '../../store';
17
20
  import { RepeatContext } from './Repeat';
18
21
  import { DialogCloseContext } from '../PassageDialog';
22
+ import { ItemEditContext } from '../../utils/control-edits';
19
23
 
20
24
  const frozenAlways = () => true;
21
25
 
@@ -35,7 +39,8 @@ const frozenAlways = () => true;
35
39
  * the owning macro sees and provides them again: the locals scope (read
36
40
  * through a live view, so a local assigned earlier in the body is visible),
37
41
  * nobr/inline rendering, the enclosing block widget's {@children}, the
38
- * enclosing {repeat}'s {stop} and the enclosing dialog's close callback.
42
+ * enclosing {repeat}'s {stop} and the enclosing dialog's close callback and whether it runs in a {for} iteration
43
+ * (#411) and the owning passage's departure (#410).
39
44
  * The body then behaves as if it rendered in place.
40
45
  *
41
46
  * The body's macros read story state from the store. Run from mutation code
@@ -50,6 +55,8 @@ export function useDetachedBody(): (children: ASTNode[]) => void {
50
55
  const widgetChildren = useContext(WidgetChildrenContext);
51
56
  const repeat = useContext(RepeatContext);
52
57
  const closeDialog = useContext(DialogCloseContext);
58
+ const inIteration = useContext(ItemEditContext);
59
+ const hasLeft = useContext(OutgoingContext);
53
60
 
54
61
  const mounted = useRef<HTMLElement[]>([]);
55
62
  useEffect(() => {
@@ -81,7 +88,11 @@ export function useDetachedBody(): (children: ASTNode[]) => void {
81
88
  <WidgetChildrenContext.Provider value={widgetChildren}>
82
89
  <RepeatContext.Provider value={repeat}>
83
90
  <DialogCloseContext.Provider value={closeDialog}>
84
- {renderNodes(children, { nobr, inline, locals })}
91
+ <ItemEditContext.Provider value={inIteration}>
92
+ <OutgoingContext.Provider value={hasLeft}>
93
+ {renderNodes(children, { nobr, inline, locals })}
94
+ </OutgoingContext.Provider>
95
+ </ItemEditContext.Provider>
85
96
  </DialogCloseContext.Provider>
86
97
  </RepeatContext.Provider>
87
98
  </WidgetChildrenContext.Provider>
@@ -216,11 +216,19 @@ export function defineMacro<const P extends readonly ParameterDef[] = []>(
216
216
  if (config.storeVar) {
217
217
  const firstToken = readBoundVariable(props.rawArgs).replace(/["']/g, '');
218
218
 
219
- if (firstToken.startsWith('%')) {
219
+ // Only story variables ($name) can be bound: another sigil would be
220
+ // read as a story key spelled with it (#414)
221
+ const unbindable: Record<string, string> = {
222
+ '%': 'transient variables',
223
+ _: 'temporary variables',
224
+ '@': 'locals',
225
+ };
226
+ const kind = unbindable[firstToken.charAt(0)];
227
+ if (kind) {
220
228
  return h(
221
229
  'span',
222
230
  { class: 'error' },
223
- `{${config.name}}: transient variables (%${firstToken.slice(1)}) cannot be bound to input macros`,
231
+ `{${config.name}}: ${kind} (${firstToken}) cannot be bound to input macros`,
224
232
  );
225
233
  }
226
234
 
@@ -9,6 +9,8 @@ const FOCUSABLE = [
9
9
  'select:not([disabled])',
10
10
  'textarea:not([disabled])',
11
11
  'iframe',
12
+ 'audio[controls]',
13
+ 'video[controls]',
12
14
  'summary',
13
15
  '[contenteditable]',
14
16
  '[tabindex]',