@rohal12/spindle 0.59.8 → 0.59.10
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 +321 -74
- package/package.json +1 -1
- package/src/class-registry.ts +22 -1
- package/src/components/PassageDialog.tsx +4 -3
- package/src/components/macros/Button.tsx +1 -0
- package/src/components/macros/Cycle.tsx +1 -0
- package/src/components/macros/Dialog.tsx +1 -0
- package/src/components/macros/Do.tsx +4 -12
- package/src/components/macros/MacroError.tsx +1 -1
- package/src/components/macros/MenubarButtons.tsx +1 -0
- package/src/components/macros/PassageDisplay.tsx +61 -11
- package/src/components/macros/SaveManager.tsx +27 -1
- package/src/components/macros/Type.tsx +57 -8
- package/src/components/macros/WidgetInvocation.tsx +20 -1
- package/src/components/macros/detached-body.tsx +22 -16
- package/src/components/macros/input-macro.ts +18 -3
- package/src/hooks/use-merged-locals.ts +5 -2
- package/src/hooks/use-story-fields.ts +16 -1
- package/src/markup/render.tsx +9 -4
- package/src/saves/format.ts +10 -1
- package/src/saves/save-manager.ts +99 -18
- package/src/saves/types.ts +3 -0
- package/src/store.ts +44 -3
- package/src/story-init.ts +13 -1
- package/src/structural.ts +6 -2
- package/src/styles.css +9 -0
- package/src/triggers.ts +30 -0
- package/src/utils/stable-key.ts +1 -1
package/package.json
CHANGED
package/src/class-registry.ts
CHANGED
|
@@ -51,7 +51,9 @@ export function clearRegistry(): void {
|
|
|
51
51
|
// with its message, cause, errors and own enumerable keys (not its stack);
|
|
52
52
|
// - `S`: a symbol from the global registry (Symbol.for);
|
|
53
53
|
// - `K`: a plain object holding a one-character key that JSON escapes (see
|
|
54
|
-
// GUARD_V8_KEYS)
|
|
54
|
+
// GUARD_V8_KEYS);
|
|
55
|
+
// - `R`: a RegExp whose lastIndex is not zero (the scanning cursor of a
|
|
56
|
+
// global or sticky pattern), with its source, flags and lastIndex.
|
|
55
57
|
//
|
|
56
58
|
// Everything else is refused when saving, with the path to the value:
|
|
57
59
|
// functions, unique symbols, symbol keys, instances of unregistered classes,
|
|
@@ -61,6 +63,7 @@ const CLASS_PREFIX = 'c:';
|
|
|
61
63
|
const ERROR_PREFIX = 'E:';
|
|
62
64
|
const SYMBOL_TAG = 'S';
|
|
63
65
|
const KEYS_TAG = 'K';
|
|
66
|
+
const REGEXP_TAG = 'R';
|
|
64
67
|
|
|
65
68
|
/**
|
|
66
69
|
* Work around Chromium issue 521080746 (V8 in Chrome/Chromium 14x-153+,
|
|
@@ -167,6 +170,9 @@ function reducers(): Record<string, (value: unknown) => unknown> {
|
|
|
167
170
|
const key = Symbol.keyFor(v);
|
|
168
171
|
return key !== undefined && [key];
|
|
169
172
|
};
|
|
173
|
+
out[REGEXP_TAG] = (v) =>
|
|
174
|
+
v instanceof RegExp &&
|
|
175
|
+
v.lastIndex !== 0 && [v.source, v.flags, v.lastIndex];
|
|
170
176
|
if (GUARD_V8_KEYS) {
|
|
171
177
|
out[KEYS_TAG] = (v) => {
|
|
172
178
|
if (!isObject(v) || Object.getPrototypeOf(v) !== Object.prototype) {
|
|
@@ -264,6 +270,21 @@ function reviverFor(key: string | symbol) {
|
|
|
264
270
|
};
|
|
265
271
|
}
|
|
266
272
|
if (key === KEYS_TAG) return reviveKeyed;
|
|
273
|
+
if (key === REGEXP_TAG) {
|
|
274
|
+
return (data: unknown) => {
|
|
275
|
+
if (
|
|
276
|
+
!Array.isArray(data) ||
|
|
277
|
+
typeof data[0] !== 'string' ||
|
|
278
|
+
typeof data[1] !== 'string' ||
|
|
279
|
+
!Number.isSafeInteger(data[2])
|
|
280
|
+
) {
|
|
281
|
+
throw new TypeError('spindle: Malformed regular expression');
|
|
282
|
+
}
|
|
283
|
+
const re = new RegExp(data[0], data[1]);
|
|
284
|
+
re.lastIndex = data[2] as number;
|
|
285
|
+
return re;
|
|
286
|
+
};
|
|
287
|
+
}
|
|
267
288
|
if (key.startsWith(CLASS_PREFIX)) {
|
|
268
289
|
return reviveClass(key.slice(CLASS_PREFIX.length));
|
|
269
290
|
}
|
|
@@ -83,9 +83,9 @@ export function PassageDialog({
|
|
|
83
83
|
|
|
84
84
|
const handleBackdrop = (e: MouseEvent) => {
|
|
85
85
|
if (!dismissible) return;
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
86
|
+
// Only a click on this overlay itself: a nested dialog's backdrop click
|
|
87
|
+
// bubbles through the outer overlay, whose target is not its own.
|
|
88
|
+
if (e.target === e.currentTarget) stableOnClose();
|
|
89
89
|
};
|
|
90
90
|
|
|
91
91
|
const cls = panelClass ? `dialog-panel ${panelClass}` : 'dialog-panel';
|
|
@@ -105,6 +105,7 @@ export function PassageDialog({
|
|
|
105
105
|
>
|
|
106
106
|
{showCloseButton && (
|
|
107
107
|
<button
|
|
108
|
+
type="button"
|
|
108
109
|
class="dialog-close"
|
|
109
110
|
aria-label="Close"
|
|
110
111
|
onClick={stableOnClose}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { defineMacro } from '../../define-macro';
|
|
2
|
-
import {
|
|
2
|
+
import { useRunOnce } from './MacroError';
|
|
3
3
|
|
|
4
4
|
defineMacro({
|
|
5
5
|
name: 'do',
|
|
@@ -8,16 +8,8 @@ defineMacro({
|
|
|
8
8
|
// The parser keeps the body verbatim as a single text node
|
|
9
9
|
const code = ctx.collectText(children);
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
try {
|
|
15
|
-
ctx.mutate(code);
|
|
16
|
-
} catch (err) {
|
|
17
|
-
logMacroError('do', err, location);
|
|
18
|
-
}
|
|
19
|
-
}, []);
|
|
20
|
-
|
|
21
|
-
return null;
|
|
11
|
+
// During the first render, like {set}: the macros after it in the
|
|
12
|
+
// passage run later and see its writes (a layout effect ran after them).
|
|
13
|
+
return useRunOnce('do', '', () => ctx.mutate(code));
|
|
22
14
|
},
|
|
23
15
|
});
|
|
@@ -49,7 +49,7 @@ export function useRunOnce(macro: string, rawArgs: string, effect: () => void) {
|
|
|
49
49
|
effect();
|
|
50
50
|
} catch (error) {
|
|
51
51
|
failure.current = { error };
|
|
52
|
-
logMacroError(`${macro} ${rawArgs}
|
|
52
|
+
logMacroError(`${macro} ${rawArgs}`.trim(), error, location);
|
|
53
53
|
}
|
|
54
54
|
}
|
|
55
55
|
|
|
@@ -1,6 +1,15 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
import {
|
|
2
|
+
useRef,
|
|
3
|
+
useEffect,
|
|
4
|
+
useLayoutEffect,
|
|
5
|
+
useCallback,
|
|
6
|
+
useState,
|
|
7
|
+
} from 'preact/hooks';
|
|
8
|
+
import { useStoryStore, type StoryState } from '../../store';
|
|
9
|
+
import {
|
|
10
|
+
useStoryFields,
|
|
11
|
+
FrozenStateContext,
|
|
12
|
+
} from '../../hooks/use-story-fields';
|
|
4
13
|
import { Passage, renderPassageContent } from '../Passage';
|
|
5
14
|
import { defineMacro } from '../../define-macro';
|
|
6
15
|
import { resolveTransition, type ResolvedTransition } from '../../transition';
|
|
@@ -8,7 +17,7 @@ import { resolveTransition, type ResolvedTransition } from '../../transition';
|
|
|
8
17
|
/**
|
|
9
18
|
* Create a snapshot clone of a .passage element for outgoing transitions.
|
|
10
19
|
* - Strips `id` attributes from the clone tree
|
|
11
|
-
* - Disables interaction (pointer-events, user-select)
|
|
20
|
+
* - Disables interaction (pointer-events, user-select, inert, aria-hidden)
|
|
12
21
|
* - Pauses/mutes any <audio>/<video>
|
|
13
22
|
* - Adds `.passage-snapshot` class and `data-transition` attribute
|
|
14
23
|
*/
|
|
@@ -28,6 +37,10 @@ function createSnapshot(
|
|
|
28
37
|
// Disable interaction
|
|
29
38
|
clone.style.pointerEvents = 'none';
|
|
30
39
|
clone.style.userSelect = 'none';
|
|
40
|
+
// Visual only: out of the tab order and the accessibility tree, and its
|
|
41
|
+
// controls (copied with their inline handlers) can not be activated
|
|
42
|
+
clone.setAttribute('inert', '');
|
|
43
|
+
clone.setAttribute('aria-hidden', 'true');
|
|
31
44
|
|
|
32
45
|
// Pause/mute media
|
|
33
46
|
const mediaEls = clone.querySelectorAll('audio, video');
|
|
@@ -122,6 +135,13 @@ defineMacro({
|
|
|
122
135
|
// Track previous history length to detect restart/load
|
|
123
136
|
const prevHistoryLenRef = useRef(0);
|
|
124
137
|
|
|
138
|
+
const displayedId = displayed.id;
|
|
139
|
+
const isOutgoing = useCallback(
|
|
140
|
+
(state: StoryState) => state.navigationId !== displayedId,
|
|
141
|
+
[displayedId],
|
|
142
|
+
);
|
|
143
|
+
const lastElement = useRef<preact.VNode | null>(null);
|
|
144
|
+
|
|
125
145
|
// Container ref for snapshot insertion
|
|
126
146
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
127
147
|
|
|
@@ -261,6 +281,22 @@ defineMacro({
|
|
|
261
281
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
262
282
|
}, [navigationId]);
|
|
263
283
|
|
|
284
|
+
// A new passage opens at its beginning, not where the previous one was
|
|
285
|
+
// scrolled to (#354). The page keeps its position on first display
|
|
286
|
+
// (a refresh restores it) and when the beginning is already in view.
|
|
287
|
+
const shownId = displayedPassage && !renderDeferred ? displayed.id : null;
|
|
288
|
+
const lastShownId = useRef(shownId);
|
|
289
|
+
useLayoutEffect(() => {
|
|
290
|
+
if (shownId === null) return; // between passages of a transition
|
|
291
|
+
const previous = lastShownId.current;
|
|
292
|
+
lastShownId.current = shownId;
|
|
293
|
+
if (previous === null || previous === shownId) return;
|
|
294
|
+
const el = containerRef.current?.querySelector('.passage');
|
|
295
|
+
if (el && el.getBoundingClientRect().top < 0) {
|
|
296
|
+
el.scrollIntoView?.({ block: 'start' });
|
|
297
|
+
}
|
|
298
|
+
}, [shownId]);
|
|
299
|
+
|
|
264
300
|
// When render is deferred, show StoryLoading passage or nothing
|
|
265
301
|
const effectivePassage = renderDeferred
|
|
266
302
|
? (storyData?.passages.get('StoryLoading') ?? null)
|
|
@@ -278,6 +314,23 @@ defineMacro({
|
|
|
278
314
|
|
|
279
315
|
const readyPassage = storyData?.passages.get('PassageReady');
|
|
280
316
|
|
|
317
|
+
// Between a navigation and the commit of its passage, the old passage is
|
|
318
|
+
// still mounted. It must not execute against the destination's state, so
|
|
319
|
+
// it keeps the element and the state it last rendered with (#352).
|
|
320
|
+
const outgoing = !renderDeferred && displayed.id !== navigationId;
|
|
321
|
+
const passageElement =
|
|
322
|
+
outgoing && lastElement.current
|
|
323
|
+
? lastElement.current
|
|
324
|
+
: effectivePassage && (
|
|
325
|
+
<Passage
|
|
326
|
+
passage={effectivePassage}
|
|
327
|
+
key={renderDeferred ? 'loading' : `nav-${displayed.id}`}
|
|
328
|
+
dataTransition={renderDeferred ? 'none' : resolvedTypeRef.current}
|
|
329
|
+
navigationId={renderDeferred ? undefined : displayed.id}
|
|
330
|
+
/>
|
|
331
|
+
);
|
|
332
|
+
lastElement.current = passageElement || null;
|
|
333
|
+
|
|
281
334
|
return (
|
|
282
335
|
<div
|
|
283
336
|
id={ctx.id ?? 'story'}
|
|
@@ -295,13 +348,10 @@ defineMacro({
|
|
|
295
348
|
class="passage-container"
|
|
296
349
|
ref={containerRef}
|
|
297
350
|
>
|
|
298
|
-
{
|
|
299
|
-
<
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
dataTransition={renderDeferred ? 'none' : resolvedTypeRef.current}
|
|
303
|
-
navigationId={renderDeferred ? undefined : displayed.id}
|
|
304
|
-
/>
|
|
351
|
+
{passageElement && (
|
|
352
|
+
<FrozenStateContext.Provider value={isOutgoing}>
|
|
353
|
+
{passageElement}
|
|
354
|
+
</FrozenStateContext.Provider>
|
|
305
355
|
)}
|
|
306
356
|
</div>
|
|
307
357
|
</div>
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
saveWithHooks,
|
|
22
22
|
type PlaythroughGroup,
|
|
23
23
|
} from '../../saves/save-manager';
|
|
24
|
+
import { getBackendType } from '../../saves/storage';
|
|
24
25
|
import { DialogCloseContext } from '../PassageDialog';
|
|
25
26
|
import { defineMacro } from '../../define-macro';
|
|
26
27
|
import { errorMessage } from '../../utils/error-message';
|
|
@@ -71,10 +72,15 @@ export function SaveManagerContent() {
|
|
|
71
72
|
'loadFromPayload',
|
|
72
73
|
);
|
|
73
74
|
const ifid = storyData?.ifid ?? '';
|
|
75
|
+
// Saves held in memory only (no browser storage is available) are lost
|
|
76
|
+
// when the page is reloaded or closed: told to the player, who can export
|
|
77
|
+
const [temporary, setTemporary] = useState(false);
|
|
74
78
|
|
|
75
79
|
const refresh = useCallback(async () => {
|
|
76
80
|
if (!ifid) return;
|
|
77
81
|
const data = await getSavesGrouped(ifid);
|
|
82
|
+
// After the read: the storage in use is known by now
|
|
83
|
+
setTemporary(getBackendType() === 'memory');
|
|
78
84
|
setGroups(data);
|
|
79
85
|
setLoading(false);
|
|
80
86
|
}, [ifid]);
|
|
@@ -131,7 +137,7 @@ export function SaveManagerContent() {
|
|
|
131
137
|
// Queued now, in call order with other storage operations
|
|
132
138
|
return createSave(ifid, playthrough, payload);
|
|
133
139
|
});
|
|
134
|
-
showStatus('Save created');
|
|
140
|
+
showStatus(temporary ? 'Save created (temporary)' : 'Save created');
|
|
135
141
|
await refresh();
|
|
136
142
|
} catch {
|
|
137
143
|
showStatus('Failed to create save', 'error');
|
|
@@ -276,12 +282,14 @@ export function SaveManagerContent() {
|
|
|
276
282
|
<div class="saves-header-left">
|
|
277
283
|
<div class="saves-mode-toggle">
|
|
278
284
|
<button
|
|
285
|
+
type="button"
|
|
279
286
|
class={mode === 'save' ? 'active' : ''}
|
|
280
287
|
onClick={() => setMode('save')}
|
|
281
288
|
>
|
|
282
289
|
Save
|
|
283
290
|
</button>
|
|
284
291
|
<button
|
|
292
|
+
type="button"
|
|
285
293
|
class={mode === 'load' ? 'active' : ''}
|
|
286
294
|
onClick={() => setMode('load')}
|
|
287
295
|
>
|
|
@@ -291,8 +299,19 @@ export function SaveManagerContent() {
|
|
|
291
299
|
</div>
|
|
292
300
|
</div>
|
|
293
301
|
|
|
302
|
+
{temporary && (
|
|
303
|
+
<div
|
|
304
|
+
class="saves-notice"
|
|
305
|
+
role="status"
|
|
306
|
+
>
|
|
307
|
+
Saves can not be kept in this browser: they are lost when the page is
|
|
308
|
+
reloaded or closed. Export a save to keep it.
|
|
309
|
+
</div>
|
|
310
|
+
)}
|
|
311
|
+
|
|
294
312
|
<div class="saves-toolbar">
|
|
295
313
|
<button
|
|
314
|
+
type="button"
|
|
296
315
|
class="saves-toolbar-button"
|
|
297
316
|
onClick={handleImport}
|
|
298
317
|
>
|
|
@@ -376,6 +395,7 @@ export function SaveManagerContent() {
|
|
|
376
395
|
<div class="save-slot-actions">
|
|
377
396
|
{mode === 'save' ? (
|
|
378
397
|
<button
|
|
398
|
+
type="button"
|
|
379
399
|
class="save-slot-action primary"
|
|
380
400
|
onClick={() => handleOverwrite(save.meta.id)}
|
|
381
401
|
>
|
|
@@ -383,6 +403,7 @@ export function SaveManagerContent() {
|
|
|
383
403
|
</button>
|
|
384
404
|
) : (
|
|
385
405
|
<button
|
|
406
|
+
type="button"
|
|
386
407
|
class="save-slot-action primary"
|
|
387
408
|
onClick={() => handleLoad(save)}
|
|
388
409
|
>
|
|
@@ -390,18 +411,21 @@ export function SaveManagerContent() {
|
|
|
390
411
|
</button>
|
|
391
412
|
)}
|
|
392
413
|
<button
|
|
414
|
+
type="button"
|
|
393
415
|
class="save-slot-action"
|
|
394
416
|
onClick={() => handleRenameStart(save)}
|
|
395
417
|
>
|
|
396
418
|
Rename
|
|
397
419
|
</button>
|
|
398
420
|
<button
|
|
421
|
+
type="button"
|
|
399
422
|
class="save-slot-action"
|
|
400
423
|
onClick={() => handleExport(save.meta.id)}
|
|
401
424
|
>
|
|
402
425
|
Export
|
|
403
426
|
</button>
|
|
404
427
|
<button
|
|
428
|
+
type="button"
|
|
405
429
|
class="save-slot-action danger"
|
|
406
430
|
onClick={() => handleDelete(save.meta.id)}
|
|
407
431
|
>
|
|
@@ -413,6 +437,7 @@ export function SaveManagerContent() {
|
|
|
413
437
|
|
|
414
438
|
{mode === 'save' && isCurrentPt && (
|
|
415
439
|
<button
|
|
440
|
+
type="button"
|
|
416
441
|
class="save-slot-new"
|
|
417
442
|
onClick={handleNewSave}
|
|
418
443
|
>
|
|
@@ -432,6 +457,7 @@ export function SaveManagerContent() {
|
|
|
432
457
|
<div class="playthrough-group">
|
|
433
458
|
<div class="playthrough-saves">
|
|
434
459
|
<button
|
|
460
|
+
type="button"
|
|
435
461
|
class="save-slot-new"
|
|
436
462
|
onClick={handleNewSave}
|
|
437
463
|
>
|
|
@@ -1,15 +1,54 @@
|
|
|
1
1
|
import { defineMacro } from '../../define-macro';
|
|
2
2
|
import { DELAY_PARAMETER } from './macro-args';
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* The clip-path that shows the first `count` characters of `root`'s text in
|
|
6
|
+
* reading order: the boxes the browser laid out for them, each wrapped line
|
|
7
|
+
* as far as its last revealed character, as one path in the coordinates of
|
|
8
|
+
* `inner`'s box. Undefined where there is no layout to measure.
|
|
9
|
+
*/
|
|
10
|
+
function revealedPath(
|
|
11
|
+
root: HTMLElement,
|
|
12
|
+
inner: HTMLElement,
|
|
13
|
+
count: number,
|
|
14
|
+
): string | undefined {
|
|
15
|
+
const range = document.createRange();
|
|
16
|
+
if (!range.getClientRects) return undefined;
|
|
17
|
+
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
|
18
|
+
let left = count;
|
|
19
|
+
let end: { node: Node; offset: number } | undefined;
|
|
20
|
+
for (let n = walker.nextNode(); n && left > 0; n = walker.nextNode()) {
|
|
21
|
+
const length = n.nodeValue?.length ?? 0;
|
|
22
|
+
end = { node: n, offset: Math.min(left, length) };
|
|
23
|
+
left -= length;
|
|
24
|
+
}
|
|
25
|
+
if (!end) return 'inset(0 100% 0 0)';
|
|
26
|
+
range.setStart(root, 0);
|
|
27
|
+
range.setEnd(end.node, end.offset);
|
|
28
|
+
const rects = range.getClientRects();
|
|
29
|
+
if (rects.length === 0) return undefined;
|
|
30
|
+
const origin = inner.getBoundingClientRect();
|
|
31
|
+
let path = '';
|
|
32
|
+
for (const r of Array.from(rects)) {
|
|
33
|
+
if (r.width === 0 && r.height === 0) continue;
|
|
34
|
+
const x = (r.left - origin.left).toFixed(2);
|
|
35
|
+
const y = (r.top - origin.top).toFixed(2);
|
|
36
|
+
path += `M${x} ${y}h${r.width.toFixed(2)}v${r.height.toFixed(2)}h-${r.width.toFixed(2)}z`;
|
|
37
|
+
}
|
|
38
|
+
return path ? `path("${path}")` : undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
4
41
|
defineMacro({
|
|
5
42
|
name: 'type',
|
|
6
43
|
block: true,
|
|
7
44
|
interpolate: true,
|
|
8
45
|
parameters: [DELAY_PARAMETER],
|
|
9
46
|
render({ children = [] }, ctx) {
|
|
10
|
-
const { useState, useEffect, useRef } = ctx.hooks;
|
|
47
|
+
const { useState, useEffect, useLayoutEffect, useRef } = ctx.hooks;
|
|
11
48
|
const speed = ctx.args.delay ?? 0;
|
|
12
49
|
const containerRef = useRef<HTMLSpanElement>(null);
|
|
50
|
+
const innerRef = useRef<HTMLSpanElement>(null);
|
|
51
|
+
const [clip, setClip] = useState<string | undefined>();
|
|
13
52
|
const [totalChars, setTotalChars] = useState(0);
|
|
14
53
|
const [visibleChars, setVisibleChars] = useState(0);
|
|
15
54
|
const visibleCharsRef = useRef(0);
|
|
@@ -44,6 +83,21 @@ defineMacro({
|
|
|
44
83
|
|
|
45
84
|
const done = visibleChars >= totalChars && totalChars > 0;
|
|
46
85
|
|
|
86
|
+
// Clip to the characters revealed so far, as laid out: remeasured when
|
|
87
|
+
// the count changes and when the width the text wraps in does.
|
|
88
|
+
useLayoutEffect(() => {
|
|
89
|
+
const root = containerRef.current;
|
|
90
|
+
const inner = innerRef.current;
|
|
91
|
+
if (!root || !inner || totalChars === 0 || done) {
|
|
92
|
+
setClip(undefined);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const measure = () => setClip(revealedPath(root, inner, visibleChars));
|
|
96
|
+
measure();
|
|
97
|
+
window.addEventListener('resize', measure);
|
|
98
|
+
return () => window.removeEventListener('resize', measure);
|
|
99
|
+
}, [visibleChars, totalChars, done]);
|
|
100
|
+
|
|
47
101
|
const cls = [
|
|
48
102
|
'macro-type',
|
|
49
103
|
done ? 'macro-type-done' : '',
|
|
@@ -57,19 +111,14 @@ defineMacro({
|
|
|
57
111
|
id={ctx.id}
|
|
58
112
|
class={cls}
|
|
59
113
|
ref={containerRef}
|
|
60
|
-
style={{
|
|
61
|
-
clipPath: totalChars > 0 && !done ? undefined : undefined,
|
|
62
|
-
}}
|
|
63
114
|
>
|
|
64
115
|
<span
|
|
116
|
+
ref={innerRef}
|
|
65
117
|
class="macro-type-inner"
|
|
66
118
|
style={{
|
|
67
119
|
display: 'inline',
|
|
68
120
|
visibility: totalChars === 0 ? 'hidden' : 'visible',
|
|
69
|
-
clipPath:
|
|
70
|
-
totalChars > 0 && !done
|
|
71
|
-
? `inset(0 ${((totalChars - visibleChars) / totalChars) * 100}% 0 0)`
|
|
72
|
-
: undefined,
|
|
121
|
+
clipPath: clip,
|
|
73
122
|
}}
|
|
74
123
|
>
|
|
75
124
|
{ctx.renderInlineNodes(children)}
|
|
@@ -15,13 +15,32 @@ interface WidgetInvocationProps {
|
|
|
15
15
|
params: string[];
|
|
16
16
|
rawArgs?: string;
|
|
17
17
|
invocationChildren?: ASTNode[];
|
|
18
|
+
className?: string;
|
|
19
|
+
id?: string;
|
|
18
20
|
}
|
|
19
21
|
|
|
20
|
-
export function WidgetInvocation({
|
|
22
|
+
export function WidgetInvocation(props: WidgetInvocationProps) {
|
|
23
|
+
const { className, id } = props;
|
|
24
|
+
const content = <WidgetContent {...props} />;
|
|
25
|
+
// Invocation selectors (`{.badge#hero Stats}`) need an element to land on.
|
|
26
|
+
if (!className && !id) return content;
|
|
27
|
+
return (
|
|
28
|
+
<span
|
|
29
|
+
class={className}
|
|
30
|
+
id={id}
|
|
31
|
+
>
|
|
32
|
+
{content}
|
|
33
|
+
</span>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function WidgetContent({
|
|
21
38
|
body,
|
|
22
39
|
params,
|
|
23
40
|
rawArgs,
|
|
24
41
|
invocationChildren,
|
|
42
|
+
className,
|
|
43
|
+
id,
|
|
25
44
|
}: WidgetInvocationProps) {
|
|
26
45
|
const renderOptions = useRenderOptions();
|
|
27
46
|
const parentValues = renderOptions.locals;
|
|
@@ -10,16 +10,20 @@ 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
14
|
import { liveLocalsView } from '../../utils/live-locals';
|
|
14
15
|
import { runWithCommittedMutations } from '../../execute-mutation';
|
|
15
16
|
import { RepeatContext } from './Repeat';
|
|
16
17
|
import { DialogCloseContext } from '../PassageDialog';
|
|
17
18
|
|
|
19
|
+
const frozenAlways = () => true;
|
|
20
|
+
|
|
18
21
|
/**
|
|
19
22
|
* Return a function that runs a macro body once, outside the passage tree:
|
|
20
23
|
* it renders `children` into a detached node, so the body's macros ({set},
|
|
21
24
|
* {if}, {goto}, ...) fire their side effects through the normal Preact
|
|
22
|
-
* pipeline. Used by {button} and {link} on click. The body
|
|
25
|
+
* pipeline. Used by {button} and {link} on click. The body's state is frozen
|
|
26
|
+
* once it has rendered, so it runs once per click. It stays mounted
|
|
23
27
|
* until the owning macro unmounts (its passage changes, say), so work it
|
|
24
28
|
* starts in an effect, such as the timer of a {timed} or {repeat}, can
|
|
25
29
|
* finish; the passage leaving cancels it.
|
|
@@ -58,21 +62,23 @@ export function useDetachedBody(): (children: ASTNode[]) => void {
|
|
|
58
62
|
const locals = liveLocalsView(updater.getValues);
|
|
59
63
|
const container = document.createElement('div');
|
|
60
64
|
render(
|
|
61
|
-
<
|
|
62
|
-
<
|
|
63
|
-
<
|
|
64
|
-
<
|
|
65
|
-
<
|
|
66
|
-
<
|
|
67
|
-
<
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
65
|
+
<FrozenStateContext.Provider value={frozenAlways}>
|
|
66
|
+
<LocalsUpdateContext.Provider value={updater}>
|
|
67
|
+
<LocalsValuesContext.Provider value={locals}>
|
|
68
|
+
<NobrContext.Provider value={nobr}>
|
|
69
|
+
<InlineContext.Provider value={inline}>
|
|
70
|
+
<WidgetChildrenContext.Provider value={widgetChildren}>
|
|
71
|
+
<RepeatContext.Provider value={repeat}>
|
|
72
|
+
<DialogCloseContext.Provider value={closeDialog}>
|
|
73
|
+
{renderNodes(children, { nobr, inline, locals })}
|
|
74
|
+
</DialogCloseContext.Provider>
|
|
75
|
+
</RepeatContext.Provider>
|
|
76
|
+
</WidgetChildrenContext.Provider>
|
|
77
|
+
</InlineContext.Provider>
|
|
78
|
+
</NobrContext.Provider>
|
|
79
|
+
</LocalsValuesContext.Provider>
|
|
80
|
+
</LocalsUpdateContext.Provider>
|
|
81
|
+
</FrozenStateContext.Provider>,
|
|
76
82
|
container,
|
|
77
83
|
);
|
|
78
84
|
mounted.current.push(container);
|
|
@@ -59,14 +59,29 @@ export function defineInputMacro(
|
|
|
59
59
|
perform: (v) => ctx.setValue!(toValue(v)),
|
|
60
60
|
});
|
|
61
61
|
|
|
62
|
+
// What the reader last typed. A number input reports an unfinished
|
|
63
|
+
// number ("-", "2e") as an empty value, so the text is kept for as
|
|
64
|
+
// long as it still converts to the variable's value, instead of being
|
|
65
|
+
// replaced by that value on every keystroke (#353).
|
|
66
|
+
const typed = ctx.hooks.useRef<string | null>(null);
|
|
67
|
+
const shown =
|
|
68
|
+
typed.current !== null &&
|
|
69
|
+
toValue(typed.current) === ctx.value &&
|
|
70
|
+
type === 'number'
|
|
71
|
+
? typed.current
|
|
72
|
+
: display(ctx.value);
|
|
73
|
+
|
|
62
74
|
return h(type ? 'input' : 'textarea', {
|
|
63
75
|
type,
|
|
64
76
|
id: ctx.id,
|
|
65
77
|
class: ctx.cls,
|
|
66
|
-
value:
|
|
78
|
+
value: shown,
|
|
67
79
|
placeholder,
|
|
68
|
-
onInput: (e: Event) =>
|
|
69
|
-
|
|
80
|
+
onInput: (e: Event) => {
|
|
81
|
+
const text = (e.target as HTMLInputElement).value;
|
|
82
|
+
typed.current = text;
|
|
83
|
+
ctx.setValue!(toValue(text));
|
|
84
|
+
},
|
|
70
85
|
});
|
|
71
86
|
},
|
|
72
87
|
});
|
|
@@ -12,14 +12,17 @@ export function useMergedLocals(): readonly [
|
|
|
12
12
|
Record<string, unknown>,
|
|
13
13
|
Record<string, unknown>,
|
|
14
14
|
] {
|
|
15
|
-
|
|
15
|
+
// renderCounts is read by rendered()/hasRendered() inside expressions, so
|
|
16
|
+
// it re-renders them (and renews the tuple) when an inclusion mounts.
|
|
17
|
+
const { variables, temporary, transient, renderCounts } = useStoryFields(
|
|
16
18
|
'variables',
|
|
17
19
|
'temporary',
|
|
18
20
|
'transient',
|
|
21
|
+
'renderCounts',
|
|
19
22
|
);
|
|
20
23
|
const localsValues = useContext(LocalsValuesContext);
|
|
21
24
|
|
|
22
25
|
return useMemo(() => {
|
|
23
26
|
return [variables, temporary, localsValues, transient] as const;
|
|
24
|
-
}, [variables, temporary, localsValues, transient]);
|
|
27
|
+
}, [variables, temporary, localsValues, transient, renderCounts]);
|
|
25
28
|
}
|