@rohal12/spindle 0.50.0 → 0.50.1
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 +828 -243
- package/dist/pkg/story-variables.js +898 -6
- package/package.json +1 -1
- package/src/class-registry.ts +11 -0
- package/src/components/Passage.tsx +21 -3
- package/src/components/macros/Button.tsx +2 -1
- package/src/components/macros/Do.tsx +1 -0
- package/src/components/macros/For.tsx +4 -0
- package/src/components/macros/Include.tsx +13 -6
- package/src/components/macros/MacroLink.tsx +2 -1
- package/src/components/macros/PassageDisplay.tsx +17 -10
- package/src/components/macros/SaveManager.tsx +18 -5
- package/src/components/macros/Watch.tsx +3 -2
- package/src/components/macros/WidgetInvocation.tsx +4 -0
- package/src/execute-mutation.ts +83 -31
- package/src/index.tsx +13 -19
- package/src/interpolation.ts +4 -9
- package/src/markup/render.tsx +68 -13
- package/src/markup/tokenizer.ts +150 -37
- package/src/saves/save-manager.ts +97 -39
- package/src/settings.ts +15 -0
- package/src/store.ts +209 -56
- package/src/story-api.ts +21 -19
- package/src/story-init.ts +27 -4
- package/src/story-variables.ts +182 -5
- package/src/triggers.ts +130 -28
- package/src/utils/live-locals.ts +22 -0
package/src/markup/render.tsx
CHANGED
|
@@ -124,6 +124,43 @@ const INLINE_ELEMENTS = new Set([
|
|
|
124
124
|
'wbr',
|
|
125
125
|
]);
|
|
126
126
|
|
|
127
|
+
/**
|
|
128
|
+
* HTML boolean attributes. Their presence means "on", but a parsed bare or
|
|
129
|
+
* `=""` attribute has the value '', which Preact would assign to the DOM
|
|
130
|
+
* property as a falsy value — so present ones are passed as `true` (#177).
|
|
131
|
+
*/
|
|
132
|
+
const BOOLEAN_ATTRIBUTES = new Set([
|
|
133
|
+
'allowfullscreen',
|
|
134
|
+
'async',
|
|
135
|
+
'autofocus',
|
|
136
|
+
'autoplay',
|
|
137
|
+
'checked',
|
|
138
|
+
'controls',
|
|
139
|
+
'default',
|
|
140
|
+
'defer',
|
|
141
|
+
'disabled',
|
|
142
|
+
'formnovalidate',
|
|
143
|
+
'hidden',
|
|
144
|
+
'inert',
|
|
145
|
+
'ismap',
|
|
146
|
+
'itemscope',
|
|
147
|
+
'loop',
|
|
148
|
+
'multiple',
|
|
149
|
+
'muted',
|
|
150
|
+
'nomodule',
|
|
151
|
+
'novalidate',
|
|
152
|
+
'open',
|
|
153
|
+
'playsinline',
|
|
154
|
+
'readonly',
|
|
155
|
+
'required',
|
|
156
|
+
'reversed',
|
|
157
|
+
'selected',
|
|
158
|
+
]);
|
|
159
|
+
|
|
160
|
+
function isPresentBooleanAttribute(name: string, value: string): boolean {
|
|
161
|
+
return value === '' && BOOLEAN_ATTRIBUTES.has(name.toLowerCase());
|
|
162
|
+
}
|
|
163
|
+
|
|
127
164
|
function HtmlNodeRenderer({ node }: { node: HtmlNode }) {
|
|
128
165
|
const resolve = useInterpolate();
|
|
129
166
|
const nobr = useContext(NobrContext);
|
|
@@ -131,7 +168,7 @@ function HtmlNodeRenderer({ node }: { node: HtmlNode }) {
|
|
|
131
168
|
const inSvg = useContext(SvgContext);
|
|
132
169
|
const attrs: Record<string, unknown> = {};
|
|
133
170
|
for (const [k, v] of Object.entries(node.attributes)) {
|
|
134
|
-
attrs[k] = resolve(v) ?? v;
|
|
171
|
+
attrs[k] = isPresentBooleanAttribute(k, v) ? true : (resolve(v) ?? v);
|
|
135
172
|
}
|
|
136
173
|
const isSvgRoot = node.tag.toLowerCase() === 'svg';
|
|
137
174
|
const isInline = INLINE_ELEMENTS.has(node.tag.toLowerCase());
|
|
@@ -161,7 +198,26 @@ function ChildrenSlot() {
|
|
|
161
198
|
return <>{renderNodes(childrenAST, { nobr, locals })}</>;
|
|
162
199
|
}
|
|
163
200
|
|
|
164
|
-
|
|
201
|
+
/**
|
|
202
|
+
* Stable per-node keys. Keying rendered children by AST node identity (not
|
|
203
|
+
* by position) makes Preact remount components when different content
|
|
204
|
+
* occupies the same slot — e.g. when an {if}/{switch} branch or an included
|
|
205
|
+
* passage changes — so mount-only macros like {set} and {do} run for the new
|
|
206
|
+
* content, while re-rendering the same AST keeps existing instances (#175).
|
|
207
|
+
*/
|
|
208
|
+
const nodeKeys = new WeakMap<ASTNode, string>();
|
|
209
|
+
let nextNodeKey = 0;
|
|
210
|
+
|
|
211
|
+
function nodeKey(node: ASTNode): string {
|
|
212
|
+
let key = nodeKeys.get(node);
|
|
213
|
+
if (key === undefined) {
|
|
214
|
+
key = `n${nextNodeKey++}`;
|
|
215
|
+
nodeKeys.set(node, key);
|
|
216
|
+
}
|
|
217
|
+
return key;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function renderMacro(node: MacroNode, key: string) {
|
|
165
221
|
if (isSubMacro(node.name)) return null;
|
|
166
222
|
|
|
167
223
|
const widget = getWidget(node.name);
|
|
@@ -204,14 +260,10 @@ function renderMacro(node: MacroNode, key: number) {
|
|
|
204
260
|
/**
|
|
205
261
|
* Render a non-text AST node to a Preact element.
|
|
206
262
|
*/
|
|
207
|
-
function renderSingleNode(
|
|
208
|
-
node
|
|
209
|
-
key
|
|
210
|
-
): preact.ComponentChildren {
|
|
263
|
+
function renderSingleNode(node: ASTNode): preact.ComponentChildren {
|
|
264
|
+
if (node.type === 'text') return node.value;
|
|
265
|
+
const key = nodeKey(node);
|
|
211
266
|
switch (node.type) {
|
|
212
|
-
case 'text':
|
|
213
|
-
return node.value;
|
|
214
|
-
|
|
215
267
|
case 'variable':
|
|
216
268
|
if (node.scope === 'local' && node.name === 'children') {
|
|
217
269
|
return <ChildrenSlot key={key} />;
|
|
@@ -261,7 +313,7 @@ function renderSingleNode(
|
|
|
261
313
|
*/
|
|
262
314
|
export function renderInlineNodes(nodes: ASTNode[]): preact.ComponentChildren {
|
|
263
315
|
if (nodes.length === 0) return null;
|
|
264
|
-
return nodes.map((node
|
|
316
|
+
return nodes.map((node) => renderSingleNode(node));
|
|
265
317
|
}
|
|
266
318
|
|
|
267
319
|
function hasUnclosedBacktick(s: string): boolean {
|
|
@@ -298,8 +350,11 @@ function getVariableTextValue(
|
|
|
298
350
|
* Characters/patterns that trigger CommonMark or GFM transformations.
|
|
299
351
|
* Any match → fall through to the full micromark pipeline.
|
|
300
352
|
* False positives (e.g. `-` used as text, not list) just use the slower path.
|
|
353
|
+
* Includes character references (`&`, `{`) and two-space hard line
|
|
354
|
+
* breaks, which micromark decodes / turns into <br> (#171).
|
|
301
355
|
*/
|
|
302
|
-
const MARKDOWN_SYNTAX_RE =
|
|
356
|
+
const MARKDOWN_SYNTAX_RE =
|
|
357
|
+
/[*_`#|~\[>\\\-+=]|!\[|\d+\.|&#?[a-zA-Z0-9]+;| {2}\n/;
|
|
303
358
|
const BLANK_LINE_RE = /\n\s*\n/;
|
|
304
359
|
const PLACEHOLDER_SPLIT_RE = /(<span data-tw="\d+"><\/span>)/;
|
|
305
360
|
const PLACEHOLDER_IDX_RE = /^<span data-tw="(\d+)"><\/span>$/;
|
|
@@ -358,7 +413,7 @@ export function renderNodes(
|
|
|
358
413
|
n.type === 'text' && (n.value.trim() !== '' || /\n\s*\n/.test(n.value)),
|
|
359
414
|
);
|
|
360
415
|
if (!needsMarkdown) {
|
|
361
|
-
return nodes.map((node
|
|
416
|
+
return nodes.map((node) => renderSingleNode(node));
|
|
362
417
|
}
|
|
363
418
|
|
|
364
419
|
// Build combined markdown string with placeholders for non-text nodes
|
|
@@ -375,7 +430,7 @@ export function renderNodes(
|
|
|
375
430
|
combined += getVariableTextValue(node, locals);
|
|
376
431
|
} else {
|
|
377
432
|
const phIdx = components.length;
|
|
378
|
-
components.push(renderSingleNode(node
|
|
433
|
+
components.push(renderSingleNode(node));
|
|
379
434
|
combined += `<span data-tw="${phIdx}"></span>`;
|
|
380
435
|
}
|
|
381
436
|
}
|
package/src/markup/tokenizer.ts
CHANGED
|
@@ -66,7 +66,26 @@ export type Token =
|
|
|
66
66
|
/** Tag name must start with a letter (covers standard and custom elements). */
|
|
67
67
|
const VALID_TAG_START = /[a-zA-Z]/;
|
|
68
68
|
|
|
69
|
-
|
|
69
|
+
/** HTML void elements: never have children or a closing tag. */
|
|
70
|
+
const HTML_VOID_TAGS = new Set([
|
|
71
|
+
'area',
|
|
72
|
+
'base',
|
|
73
|
+
'br',
|
|
74
|
+
'col',
|
|
75
|
+
'embed',
|
|
76
|
+
'hr',
|
|
77
|
+
'img',
|
|
78
|
+
'input',
|
|
79
|
+
'link',
|
|
80
|
+
'meta',
|
|
81
|
+
'param',
|
|
82
|
+
'source',
|
|
83
|
+
'track',
|
|
84
|
+
'wbr',
|
|
85
|
+
]);
|
|
86
|
+
|
|
87
|
+
/** Macros whose body is JavaScript source, kept verbatim instead of tokenized. */
|
|
88
|
+
const RAW_BODY_MACROS = new Set(['do']);
|
|
70
89
|
|
|
71
90
|
/**
|
|
72
91
|
* Parse a Twine link interior into display and target.
|
|
@@ -219,11 +238,15 @@ function parseHtmlAttributes(
|
|
|
219
238
|
const quote = input[j]!;
|
|
220
239
|
j++; // skip opening quote
|
|
221
240
|
const valStart = j;
|
|
222
|
-
let braceDepth = 0;
|
|
223
241
|
while (j < input.length) {
|
|
224
|
-
if (input[j] === '{')
|
|
225
|
-
|
|
226
|
-
|
|
242
|
+
if (input[j] === '{') {
|
|
243
|
+
// Skip a whole {…} interpolation so quotes inside it don't end the value
|
|
244
|
+
const closeIdx = scanBalancedBrace(input, j + 1);
|
|
245
|
+
if (closeIdx !== -1) {
|
|
246
|
+
j = closeIdx + 1;
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
} else if (input[j] === quote) break;
|
|
227
250
|
j++;
|
|
228
251
|
}
|
|
229
252
|
attributes[attrName] = input.slice(valStart, j);
|
|
@@ -243,18 +266,86 @@ function parseHtmlAttributes(
|
|
|
243
266
|
return { attributes, endIdx: j };
|
|
244
267
|
}
|
|
245
268
|
|
|
269
|
+
/**
|
|
270
|
+
* Skip a '…' or "…" string literal opening at i.
|
|
271
|
+
* Returns the index just past the closing quote, or -1 if the string is
|
|
272
|
+
* not closed on the same line (JS strings can't span lines unescaped).
|
|
273
|
+
*/
|
|
274
|
+
function skipQuoted(input: string, i: number): number {
|
|
275
|
+
const quote = input[i];
|
|
276
|
+
let j = i + 1;
|
|
277
|
+
while (j < input.length) {
|
|
278
|
+
const c = input[j];
|
|
279
|
+
if (c === '\\') j += 2;
|
|
280
|
+
else if (c === quote) return j + 1;
|
|
281
|
+
else if (c === '\n') return -1;
|
|
282
|
+
else j++;
|
|
283
|
+
}
|
|
284
|
+
return -1;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Skip a `…` template literal opening at i, including ${…} parts.
|
|
289
|
+
* Returns the index just past the closing backtick, or -1 if unclosed.
|
|
290
|
+
*/
|
|
291
|
+
function skipTemplate(input: string, i: number): number {
|
|
292
|
+
let j = i + 1;
|
|
293
|
+
while (j < input.length) {
|
|
294
|
+
const c = input[j];
|
|
295
|
+
if (c === '\\') {
|
|
296
|
+
j += 2;
|
|
297
|
+
} else if (c === '`') {
|
|
298
|
+
return j + 1;
|
|
299
|
+
} else if (c === '$' && input[j + 1] === '{') {
|
|
300
|
+
const closeIdx = scanBalancedBrace(input, j + 2);
|
|
301
|
+
if (closeIdx === -1) return -1;
|
|
302
|
+
j = closeIdx + 1;
|
|
303
|
+
} else {
|
|
304
|
+
j++;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return -1;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* A quote directly after a letter/digit is an apostrophe (don't), not a
|
|
312
|
+
* string; after a backslash it is an escaped attribute delimiter (\").
|
|
313
|
+
*/
|
|
314
|
+
const NON_STRING_QUOTE_PREFIX = /[\p{L}\p{N}_\\]/u;
|
|
315
|
+
|
|
246
316
|
/**
|
|
247
317
|
* Scan for the balanced closing } starting at position i (just past the {).
|
|
318
|
+
* Braces inside string and template literals are ignored. A quote that
|
|
319
|
+
* can't start a string (apostrophe, escaped, unterminated) counts as text.
|
|
248
320
|
* Returns the index of the closing } or -1 if unbalanced.
|
|
249
321
|
*/
|
|
250
|
-
function scanBalancedBrace(input: string, i: number): number {
|
|
322
|
+
export function scanBalancedBrace(input: string, i: number): number {
|
|
251
323
|
let depth = 1;
|
|
252
|
-
while (i < input.length
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
324
|
+
while (i < input.length) {
|
|
325
|
+
const c = input[i]!;
|
|
326
|
+
if (c === '{') {
|
|
327
|
+
depth++;
|
|
328
|
+
} else if (c === '}') {
|
|
329
|
+
if (--depth === 0) return i;
|
|
330
|
+
} else if (
|
|
331
|
+
(c === '"' || c === "'") &&
|
|
332
|
+
!(i > 0 && NON_STRING_QUOTE_PREFIX.test(input[i - 1]!))
|
|
333
|
+
) {
|
|
334
|
+
const end = skipQuoted(input, i);
|
|
335
|
+
if (end !== -1) {
|
|
336
|
+
i = end;
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
} else if (c === '`') {
|
|
340
|
+
const end = skipTemplate(input, i);
|
|
341
|
+
if (end !== -1) {
|
|
342
|
+
i = end;
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
i++;
|
|
256
347
|
}
|
|
257
|
-
return
|
|
348
|
+
return -1;
|
|
258
349
|
}
|
|
259
350
|
|
|
260
351
|
/**
|
|
@@ -277,6 +368,32 @@ export function tokenize(input: string): Token[] {
|
|
|
277
368
|
}
|
|
278
369
|
}
|
|
279
370
|
|
|
371
|
+
/**
|
|
372
|
+
* After an opening raw-body macro ({do}), emit everything up to the
|
|
373
|
+
* first {/name} as a single text token so JavaScript source is not
|
|
374
|
+
* parsed as markup. A literal "{/do}" inside the code would end it early.
|
|
375
|
+
* Without a closer, nothing is consumed and the AST builder reports it.
|
|
376
|
+
*/
|
|
377
|
+
function consumeRawBody(name: string, isClose: boolean) {
|
|
378
|
+
const lower = name.toLowerCase();
|
|
379
|
+
if (isClose || !RAW_BODY_MACROS.has(lower)) return;
|
|
380
|
+
const closeRe = new RegExp(`\\{/${lower}\\s*\\}`, 'gi');
|
|
381
|
+
closeRe.lastIndex = i;
|
|
382
|
+
const m = closeRe.exec(input);
|
|
383
|
+
if (!m) return;
|
|
384
|
+
const closeStart = m.index;
|
|
385
|
+
const closeEnd = closeStart + m[0].length;
|
|
386
|
+
flushText(closeStart);
|
|
387
|
+
tokens.push({
|
|
388
|
+
type: 'macro',
|
|
389
|
+
...parseMacroContent(input.slice(closeStart + 1, closeEnd - 1)),
|
|
390
|
+
start: closeStart,
|
|
391
|
+
end: closeEnd,
|
|
392
|
+
});
|
|
393
|
+
i = closeEnd;
|
|
394
|
+
textStart = closeEnd;
|
|
395
|
+
}
|
|
396
|
+
|
|
280
397
|
while (i < input.length) {
|
|
281
398
|
// Handle escaped braces: \{ and \}
|
|
282
399
|
if (input[i] === '\\' && (input[i + 1] === '{' || input[i + 1] === '}')) {
|
|
@@ -546,25 +663,18 @@ export function tokenize(input: string): Token[] {
|
|
|
546
663
|
|
|
547
664
|
if (charAfter !== undefined && /[a-zA-Z]/.test(charAfter)) {
|
|
548
665
|
// {.class#id macroName args}
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
let depth = 1;
|
|
553
|
-
const contentStart = i;
|
|
554
|
-
while (i < input.length && depth > 0) {
|
|
555
|
-
if (input[i] === '{') depth++;
|
|
556
|
-
else if (input[i] === '}') depth--;
|
|
557
|
-
if (depth > 0) i++;
|
|
558
|
-
}
|
|
666
|
+
// Scan to closing }, tracking brace nesting and string literals
|
|
667
|
+
const contentStart = afterSelectors;
|
|
668
|
+
const closeIdx = scanBalancedBrace(input, contentStart);
|
|
559
669
|
|
|
560
|
-
if (
|
|
670
|
+
if (closeIdx === -1) {
|
|
561
671
|
i = start + 1;
|
|
562
672
|
textStart = start;
|
|
563
673
|
continue;
|
|
564
674
|
}
|
|
565
675
|
|
|
566
|
-
const content = input.slice(contentStart,
|
|
567
|
-
i
|
|
676
|
+
const content = input.slice(contentStart, closeIdx);
|
|
677
|
+
i = closeIdx + 1; // skip closing }
|
|
568
678
|
|
|
569
679
|
const { name, rawArgs, isClose } = parseMacroContent(content);
|
|
570
680
|
const token: MacroToken = {
|
|
@@ -579,6 +689,7 @@ export function tokenize(input: string): Token[] {
|
|
|
579
689
|
if (id) token.id = id;
|
|
580
690
|
tokens.push(token);
|
|
581
691
|
textStart = i;
|
|
692
|
+
consumeRawBody(name, isClose);
|
|
582
693
|
continue;
|
|
583
694
|
}
|
|
584
695
|
|
|
@@ -755,26 +866,21 @@ export function tokenize(input: string): Token[] {
|
|
|
755
866
|
(nextChar === '/' || /[a-zA-Z]/.test(nextChar))
|
|
756
867
|
) {
|
|
757
868
|
flushText(i);
|
|
758
|
-
i++; // skip {
|
|
759
|
-
|
|
760
|
-
// Scan to closing }, tracking brace nesting for object literals
|
|
761
|
-
let depth = 1;
|
|
762
|
-
const contentStart = i;
|
|
763
|
-
while (i < input.length && depth > 0) {
|
|
764
|
-
if (input[i] === '{') depth++;
|
|
765
|
-
else if (input[i] === '}') depth--;
|
|
766
|
-
if (depth > 0) i++;
|
|
767
|
-
}
|
|
768
869
|
|
|
769
|
-
|
|
870
|
+
// Scan to closing }, tracking brace nesting (object literals)
|
|
871
|
+
// and string literals
|
|
872
|
+
const contentStart = i + 1;
|
|
873
|
+
const closeIdx = scanBalancedBrace(input, contentStart);
|
|
874
|
+
|
|
875
|
+
if (closeIdx === -1) {
|
|
770
876
|
// Unclosed macro — treat as text
|
|
771
877
|
i = start + 1;
|
|
772
878
|
textStart = start;
|
|
773
879
|
continue;
|
|
774
880
|
}
|
|
775
881
|
|
|
776
|
-
const content = input.slice(contentStart,
|
|
777
|
-
i
|
|
882
|
+
const content = input.slice(contentStart, closeIdx);
|
|
883
|
+
i = closeIdx + 1; // skip closing }
|
|
778
884
|
|
|
779
885
|
const { name, rawArgs, isClose } = parseMacroContent(content);
|
|
780
886
|
tokens.push({
|
|
@@ -786,6 +892,7 @@ export function tokenize(input: string): Token[] {
|
|
|
786
892
|
end: i,
|
|
787
893
|
});
|
|
788
894
|
textStart = i;
|
|
895
|
+
consumeRawBody(name, isClose);
|
|
789
896
|
continue;
|
|
790
897
|
}
|
|
791
898
|
|
|
@@ -817,6 +924,12 @@ export function tokenize(input: string): Token[] {
|
|
|
817
924
|
if (input[j] === '>') {
|
|
818
925
|
j++;
|
|
819
926
|
flushText(start);
|
|
927
|
+
if (HTML_VOID_TAGS.has(tagLower)) {
|
|
928
|
+
// Void elements never take a closer; drop a redundant </input>
|
|
929
|
+
textStart = j;
|
|
930
|
+
i = j;
|
|
931
|
+
continue;
|
|
932
|
+
}
|
|
820
933
|
tokens.push({
|
|
821
934
|
type: 'html',
|
|
822
935
|
tag,
|
|
@@ -10,6 +10,7 @@ import type {
|
|
|
10
10
|
import { isSavePayload } from './types';
|
|
11
11
|
import { getBackend, resetBackend } from './storage';
|
|
12
12
|
import { deepClone, serialize, deserialize } from '../class-registry';
|
|
13
|
+
import { emit } from '../event-emitter';
|
|
13
14
|
|
|
14
15
|
type TitleGenerator = (payload: SavePayload) => string;
|
|
15
16
|
|
|
@@ -94,6 +95,28 @@ export async function getCurrentPlaythroughId(
|
|
|
94
95
|
return (await getBackend()).getMeta<string>(`currentPlaythroughId.${ifid}`);
|
|
95
96
|
}
|
|
96
97
|
|
|
98
|
+
// --- Save Hooks ---
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Run a save through the `beforesave` / `aftersave` hooks. `beforesave` fires
|
|
102
|
+
* before `getPayload()` captures the state, so data a hook sets is part of the
|
|
103
|
+
* save; `aftersave` fires once `write` has stored it. Every user-facing save
|
|
104
|
+
* path goes through here.
|
|
105
|
+
*/
|
|
106
|
+
export function saveWithHooks<T>(
|
|
107
|
+
slot: string | undefined,
|
|
108
|
+
custom: Record<string, unknown> | undefined,
|
|
109
|
+
getPayload: () => SavePayload,
|
|
110
|
+
write: (payload: SavePayload) => Promise<T>,
|
|
111
|
+
): Promise<T> {
|
|
112
|
+
emit('beforesave', slot, custom);
|
|
113
|
+
const payload = getPayload();
|
|
114
|
+
return write(payload).then((result) => {
|
|
115
|
+
emit('aftersave', slot);
|
|
116
|
+
return result;
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
97
120
|
// --- Save CRUD ---
|
|
98
121
|
|
|
99
122
|
export async function createSave(
|
|
@@ -157,18 +180,28 @@ export async function overwriteSave(
|
|
|
157
180
|
return updated;
|
|
158
181
|
}
|
|
159
182
|
|
|
183
|
+
/**
|
|
184
|
+
* Turn a stored (serialized) payload into a live one, restoring class
|
|
185
|
+
* instances and built-ins. Returns a new payload; the stored one is left
|
|
186
|
+
* untouched. Payloads handed to the store's `loadFromPayload()` must be live.
|
|
187
|
+
*/
|
|
188
|
+
export function deserializePayload(payload: SavePayload): SavePayload {
|
|
189
|
+
return {
|
|
190
|
+
...payload,
|
|
191
|
+
variables: deserialize(payload.variables),
|
|
192
|
+
history: payload.history.map((m) => ({
|
|
193
|
+
...m,
|
|
194
|
+
variables: deserialize(m.variables),
|
|
195
|
+
})),
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
160
199
|
export async function loadSave(
|
|
161
200
|
saveId: string,
|
|
162
201
|
): Promise<SavePayload | undefined> {
|
|
163
202
|
const record = await (await getBackend()).getSave(saveId);
|
|
164
203
|
if (!record) return undefined;
|
|
165
|
-
|
|
166
|
-
payload.variables = deserialize(payload.variables);
|
|
167
|
-
payload.history = payload.history.map((m) => ({
|
|
168
|
-
...m,
|
|
169
|
-
variables: deserialize(m.variables),
|
|
170
|
-
}));
|
|
171
|
-
return payload;
|
|
204
|
+
return deserializePayload(record.payload);
|
|
172
205
|
}
|
|
173
206
|
|
|
174
207
|
export async function deleteSaveById(saveId: string): Promise<void> {
|
|
@@ -267,26 +300,66 @@ const AUTOSAVE_KEY_PREFIX = 'autosave.';
|
|
|
267
300
|
const SLOT_KEY_PREFIX = 'slot.';
|
|
268
301
|
const SLOT_INDEX_KEY_PREFIX = 'slotIndex.';
|
|
269
302
|
|
|
303
|
+
/**
|
|
304
|
+
* Whether `slot` names a slot. The default (autosave) slot is addressed by
|
|
305
|
+
* omitting `slot` or by `''`, the identifier its SaveInfo reports.
|
|
306
|
+
*/
|
|
307
|
+
function isNamedSlot(slot: string | undefined): slot is string {
|
|
308
|
+
return slot != null && slot !== '';
|
|
309
|
+
}
|
|
310
|
+
|
|
270
311
|
function slotMetaKey(ifid: string, slot?: string): string {
|
|
271
|
-
return slot
|
|
312
|
+
return isNamedSlot(slot)
|
|
272
313
|
? `${SLOT_KEY_PREFIX}${slot}.${ifid}`
|
|
273
314
|
: `${AUTOSAVE_KEY_PREFIX}${ifid}`;
|
|
274
315
|
}
|
|
275
316
|
|
|
317
|
+
/** Tail of the queue that serializes slot index updates. */
|
|
318
|
+
let slotIndexQueue: Promise<unknown> = Promise.resolve();
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Read-modify-write the per-story slot index. Updates are queued so that
|
|
322
|
+
* concurrent saves, imports and deletes can't overwrite each other's change.
|
|
323
|
+
*/
|
|
324
|
+
function updateSlotIndex(
|
|
325
|
+
ifid: string,
|
|
326
|
+
update: (slots: string[]) => string[],
|
|
327
|
+
): Promise<void> {
|
|
328
|
+
const run = slotIndexQueue.then(async () => {
|
|
329
|
+
const backend = await getBackend();
|
|
330
|
+
const indexKey = `${SLOT_INDEX_KEY_PREFIX}${ifid}`;
|
|
331
|
+
const existing = (await backend.getMeta<string[]>(indexKey)) ?? [];
|
|
332
|
+
const updated = update(existing);
|
|
333
|
+
if (updated !== existing) await backend.setMeta(indexKey, updated);
|
|
334
|
+
});
|
|
335
|
+
slotIndexQueue = run.catch(() => {});
|
|
336
|
+
return run;
|
|
337
|
+
}
|
|
338
|
+
|
|
276
339
|
/** Record a named slot in the per-story slot index (no-op for the default slot). */
|
|
277
|
-
|
|
278
|
-
if (slot
|
|
279
|
-
|
|
340
|
+
function addToSlotIndex(ifid: string, slot?: string): Promise<void> {
|
|
341
|
+
if (!isNamedSlot(slot)) return Promise.resolve();
|
|
342
|
+
return updateSlotIndex(ifid, (slots) =>
|
|
343
|
+
slots.includes(slot) ? slots : [...slots, slot],
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** Remove a named slot from the per-story slot index (no-op for the default slot). */
|
|
348
|
+
function removeFromSlotIndex(ifid: string, slot?: string): Promise<void> {
|
|
349
|
+
if (!isNamedSlot(slot)) return Promise.resolve();
|
|
350
|
+
return updateSlotIndex(ifid, (slots) => slots.filter((s) => s !== slot));
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** Named slots in the per-story slot index. */
|
|
354
|
+
async function getIndexedSlots(ifid: string): Promise<string[]> {
|
|
280
355
|
const indexKey = `${SLOT_INDEX_KEY_PREFIX}${ifid}`;
|
|
281
|
-
const
|
|
282
|
-
|
|
283
|
-
await backend.setMeta(indexKey, [...existing, slot]);
|
|
284
|
-
}
|
|
356
|
+
const slots = (await (await getBackend()).getMeta<string[]>(indexKey)) ?? [];
|
|
357
|
+
return slots.filter(isNamedSlot);
|
|
285
358
|
}
|
|
286
359
|
|
|
287
360
|
function toSaveInfo(record: SaveRecord, slot?: string): SaveInfo {
|
|
288
361
|
return {
|
|
289
|
-
slot: slot
|
|
362
|
+
slot: isNamedSlot(slot) ? slot : '',
|
|
290
363
|
title: record.meta.title,
|
|
291
364
|
passage: record.meta.passage,
|
|
292
365
|
createdAt: record.meta.createdAt,
|
|
@@ -313,8 +386,8 @@ export async function quickSave(
|
|
|
313
386
|
|
|
314
387
|
// Create new save
|
|
315
388
|
const record = await createSave(ifid, playthroughId, payload, {
|
|
316
|
-
isAutosave: !slot,
|
|
317
|
-
...(slot
|
|
389
|
+
isAutosave: !isNamedSlot(slot),
|
|
390
|
+
...(isNamedSlot(slot) ? { slot } : {}),
|
|
318
391
|
...custom,
|
|
319
392
|
});
|
|
320
393
|
await backend.setMeta(metaKey, record.meta.id);
|
|
@@ -360,9 +433,7 @@ export async function populateKnownSaves(
|
|
|
360
433
|
}
|
|
361
434
|
|
|
362
435
|
// Check named slots from the index
|
|
363
|
-
const
|
|
364
|
-
const slots = (await (await getBackend()).getMeta<string[]>(indexKey)) ?? [];
|
|
365
|
-
for (const slot of slots) {
|
|
436
|
+
for (const slot of await getIndexedSlots(ifid)) {
|
|
366
437
|
if (await hasQuickSave(ifid, slot)) {
|
|
367
438
|
result[slot] = true;
|
|
368
439
|
}
|
|
@@ -399,9 +470,7 @@ export async function listSlotSaves(ifid: string): Promise<SaveInfo[]> {
|
|
|
399
470
|
if (defaultInfo) result.push(defaultInfo);
|
|
400
471
|
|
|
401
472
|
// Check named slots from the index
|
|
402
|
-
const
|
|
403
|
-
const slots = (await (await getBackend()).getMeta<string[]>(indexKey)) ?? [];
|
|
404
|
-
for (const slot of slots) {
|
|
473
|
+
for (const slot of await getIndexedSlots(ifid)) {
|
|
405
474
|
const info = await getSlotSaveInfo(ifid, slot);
|
|
406
475
|
if (info) result.push(info);
|
|
407
476
|
}
|
|
@@ -425,12 +494,7 @@ export async function deleteSlotSave(
|
|
|
425
494
|
await backend.deleteMeta(metaKey);
|
|
426
495
|
|
|
427
496
|
// Remove from slot index if named
|
|
428
|
-
|
|
429
|
-
const indexKey = `${SLOT_INDEX_KEY_PREFIX}${ifid}`;
|
|
430
|
-
const existing = (await backend.getMeta<string[]>(indexKey)) ?? [];
|
|
431
|
-
const updated = existing.filter((s) => s !== slot);
|
|
432
|
-
await backend.setMeta(indexKey, updated);
|
|
433
|
-
}
|
|
497
|
+
await removeFromSlotIndex(ifid, slot);
|
|
434
498
|
}
|
|
435
499
|
|
|
436
500
|
// --- Session Persistence (survives F5, cleared on tab close) ---
|
|
@@ -458,13 +522,7 @@ export function loadSession(ifid: string): SavePayload | undefined {
|
|
|
458
522
|
if (!raw) return undefined;
|
|
459
523
|
const parsed: unknown = JSON.parse(raw);
|
|
460
524
|
if (!isSavePayload(parsed)) return undefined;
|
|
461
|
-
|
|
462
|
-
payload.variables = deserialize(payload.variables);
|
|
463
|
-
payload.history = payload.history.map((m) => ({
|
|
464
|
-
...m,
|
|
465
|
-
variables: deserialize(m.variables),
|
|
466
|
-
}));
|
|
467
|
-
return payload;
|
|
525
|
+
return deserializePayload(parsed);
|
|
468
526
|
} catch {
|
|
469
527
|
return undefined;
|
|
470
528
|
}
|
|
@@ -578,8 +636,8 @@ export async function importSlotSave(
|
|
|
578
636
|
const { slot: _exportedSlot, ...custom } = record.meta.custom ?? {};
|
|
579
637
|
record.meta.custom = {
|
|
580
638
|
...custom,
|
|
581
|
-
isAutosave: !slot,
|
|
582
|
-
...(slot
|
|
639
|
+
isAutosave: !isNamedSlot(slot),
|
|
640
|
+
...(isNamedSlot(slot) ? { slot } : {}),
|
|
583
641
|
};
|
|
584
642
|
|
|
585
643
|
const backend = await getBackend();
|
package/src/settings.ts
CHANGED
|
@@ -27,6 +27,7 @@ export type SettingDef =
|
|
|
27
27
|
const definitions = new Map<string, SettingDef>();
|
|
28
28
|
let values: Record<string, unknown> = {};
|
|
29
29
|
let storageLoaded = false;
|
|
30
|
+
let unsubscribeStoryData: (() => void) | null = null;
|
|
30
31
|
|
|
31
32
|
function storageKey(): string {
|
|
32
33
|
const storyData = useStoryStore.getState().storyData;
|
|
@@ -40,6 +41,20 @@ function persist(): void {
|
|
|
40
41
|
|
|
41
42
|
function loadFromStorage(): void {
|
|
42
43
|
if (storageLoaded) return;
|
|
44
|
+
// Settings registered from Story JavaScript run before store.init(), when
|
|
45
|
+
// the IFID (and thus the storage key) is not known yet. Defer the load
|
|
46
|
+
// until story data arrives instead of reading `spindle.unknown.settings`.
|
|
47
|
+
if (!useStoryStore.getState().storyData?.ifid) {
|
|
48
|
+
if (!unsubscribeStoryData) {
|
|
49
|
+
unsubscribeStoryData = useStoryStore.subscribe((state) => {
|
|
50
|
+
if (!state.storyData?.ifid) return;
|
|
51
|
+
unsubscribeStoryData?.();
|
|
52
|
+
unsubscribeStoryData = null;
|
|
53
|
+
loadFromStorage();
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
43
58
|
storageLoaded = true;
|
|
44
59
|
try {
|
|
45
60
|
const raw = localStorage.getItem(storageKey());
|