@rohal12/spindle 0.59.7 → 0.59.9
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 +332 -87
- package/dist/pkg/story-variables.js +13 -2
- package/package.json +1 -1
- package/src/class-registry.ts +22 -1
- package/src/components/App.tsx +4 -4
- 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 +9 -5
- package/src/components/macros/PassageDisplay.tsx +19 -5
- package/src/components/macros/SaveManager.tsx +42 -3
- package/src/components/macros/Type.tsx +57 -8
- package/src/components/macros/detached-body.tsx +16 -3
- package/src/expression.ts +9 -21
- package/src/hooks/use-modal-focus.ts +15 -1
- package/src/quick-actions.ts +23 -0
- package/src/saves/format.ts +10 -1
- package/src/saves/save-manager.ts +66 -19
- package/src/saves/storage.ts +10 -8
- package/src/saves/types.ts +3 -0
- package/src/settings.ts +5 -1
- package/src/store.ts +41 -6
- package/src/story-init.ts +13 -1
- package/src/structural.ts +6 -2
- package/src/styles.css +14 -0
- package/src/triggers.ts +30 -0
- package/src/utils/stable-key.ts +1 -1
- package/src/widgets/widget-def.ts +15 -3
|
@@ -10269,7 +10269,13 @@ function widgetDef({ name = "", parameters = "" }) {
|
|
|
10269
10269
|
function parseWidgetDef(rawArgs) {
|
|
10270
10270
|
return widgetDef(parseMacroArgs(rawArgs, WIDGET_PARAMETERS));
|
|
10271
10271
|
}
|
|
10272
|
-
|
|
10272
|
+
/**
|
|
10273
|
+
* A definition: its arguments (read like a {widget} macro's, see
|
|
10274
|
+
* parseWidgetDef) and its body. Macro names are not case-sensitive.
|
|
10275
|
+
*/
|
|
10276
|
+
var WIDGET_DEFINITION = /\{widget\s+([^}]*)\}([\s\S]*?)\{\/widget\}/gi;
|
|
10277
|
+
/** `{@children}`, also with selectors such as `{.highlight @children}`. */
|
|
10278
|
+
var CHILDREN_PLACEHOLDER = /\{[^{}]*@children\s*\}/;
|
|
10273
10279
|
/**
|
|
10274
10280
|
* The names of the block widgets (those whose body renders `{@children}`)
|
|
10275
10281
|
* that StoryInit and the passages tagged `widget` define. They must be
|
|
@@ -10280,7 +10286,12 @@ function blockWidgetNames(passages) {
|
|
|
10280
10286
|
const names = [];
|
|
10281
10287
|
for (const passage of passages) {
|
|
10282
10288
|
if (passage.name !== "StoryInit" && !passage.tags?.includes("widget")) continue;
|
|
10283
|
-
for (const match of passage.content.matchAll(
|
|
10289
|
+
for (const match of passage.content.matchAll(WIDGET_DEFINITION)) {
|
|
10290
|
+
if (!CHILDREN_PLACEHOLDER.test(match[2])) continue;
|
|
10291
|
+
try {
|
|
10292
|
+
names.push(parseWidgetDef(match[1]).name);
|
|
10293
|
+
} catch {}
|
|
10294
|
+
}
|
|
10284
10295
|
}
|
|
10285
10296
|
return names;
|
|
10286
10297
|
}
|
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
|
}
|
package/src/components/App.tsx
CHANGED
|
@@ -5,6 +5,7 @@ import { NobrContext } from '../markup/render';
|
|
|
5
5
|
import { StoryInterface } from './StoryInterface';
|
|
6
6
|
import { TriggerDialogHost } from './TriggerDialogHost';
|
|
7
7
|
import { RuntimeErrors } from './RuntimeErrors';
|
|
8
|
+
import { quickLoad, quickSave } from '../quick-actions';
|
|
8
9
|
|
|
9
10
|
export function App() {
|
|
10
11
|
const { storyData, currentPassage, nobr } = useStoryFields(
|
|
@@ -15,14 +16,13 @@ export function App() {
|
|
|
15
16
|
|
|
16
17
|
useEffect(() => {
|
|
17
18
|
const onKeyDown = (e: KeyboardEvent) => {
|
|
18
|
-
const { quickSaveKey, quickLoadKey
|
|
19
|
-
useStoryStore.getState();
|
|
19
|
+
const { quickSaveKey, quickLoadKey } = useStoryStore.getState();
|
|
20
20
|
if (quickSaveKey !== null && e.key === quickSaveKey) {
|
|
21
21
|
e.preventDefault();
|
|
22
|
-
|
|
22
|
+
quickSave();
|
|
23
23
|
} else if (quickLoadKey !== null && e.key === quickLoadKey) {
|
|
24
24
|
e.preventDefault();
|
|
25
|
-
|
|
25
|
+
quickLoad();
|
|
26
26
|
}
|
|
27
27
|
};
|
|
28
28
|
document.addEventListener('keydown', onKeyDown);
|
|
@@ -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
|
|
|
@@ -2,6 +2,7 @@ import { useState } from 'preact/hooks';
|
|
|
2
2
|
import { useStoryStore } from '../../store';
|
|
3
3
|
import { useStoryFields } from '../../hooks/use-story-fields';
|
|
4
4
|
import { settings } from '../../settings';
|
|
5
|
+
import { quickLoad, quickSave } from '../../quick-actions';
|
|
5
6
|
import { defineMacro } from '../../define-macro';
|
|
6
7
|
import { PassageDialog } from '../PassageDialog';
|
|
7
8
|
import type { ActionType } from '../../action-registry';
|
|
@@ -62,6 +63,7 @@ export function defineMenubarAction(config: MenubarActionConfig) {
|
|
|
62
63
|
return (
|
|
63
64
|
<>
|
|
64
65
|
<button
|
|
66
|
+
type="button"
|
|
65
67
|
id={ctx.id}
|
|
66
68
|
class={cls}
|
|
67
69
|
onClick={perform}
|
|
@@ -110,8 +112,11 @@ defineMenubarAction({
|
|
|
110
112
|
label: 'QuickSave',
|
|
111
113
|
actionType: 'save',
|
|
112
114
|
setup: () => {
|
|
113
|
-
const {
|
|
114
|
-
return {
|
|
115
|
+
const { quickSaveKey } = useStoryFields('quickSaveKey');
|
|
116
|
+
return {
|
|
117
|
+
perform: quickSave,
|
|
118
|
+
title: hotkeyTitle('Quick Save', quickSaveKey),
|
|
119
|
+
};
|
|
115
120
|
},
|
|
116
121
|
});
|
|
117
122
|
|
|
@@ -122,14 +127,13 @@ defineMenubarAction({
|
|
|
122
127
|
confirm: 'Load saved game? Current progress will be lost.',
|
|
123
128
|
setup: () => {
|
|
124
129
|
// knownSaves: re-render when the saves change, as hasSave() reads them
|
|
125
|
-
const {
|
|
126
|
-
'load',
|
|
130
|
+
const { hasSave, quickLoadKey } = useStoryFields(
|
|
127
131
|
'hasSave',
|
|
128
132
|
'quickLoadKey',
|
|
129
133
|
'knownSaves',
|
|
130
134
|
);
|
|
131
135
|
return {
|
|
132
|
-
perform:
|
|
136
|
+
perform: quickLoad,
|
|
133
137
|
disabled: !hasSave(),
|
|
134
138
|
title: hotkeyTitle('Quick Load', quickLoadKey),
|
|
135
139
|
};
|
|
@@ -8,7 +8,7 @@ import { resolveTransition, type ResolvedTransition } from '../../transition';
|
|
|
8
8
|
/**
|
|
9
9
|
* Create a snapshot clone of a .passage element for outgoing transitions.
|
|
10
10
|
* - Strips `id` attributes from the clone tree
|
|
11
|
-
* - Disables interaction (pointer-events, user-select)
|
|
11
|
+
* - Disables interaction (pointer-events, user-select, inert, aria-hidden)
|
|
12
12
|
* - Pauses/mutes any <audio>/<video>
|
|
13
13
|
* - Adds `.passage-snapshot` class and `data-transition` attribute
|
|
14
14
|
*/
|
|
@@ -28,6 +28,10 @@ function createSnapshot(
|
|
|
28
28
|
// Disable interaction
|
|
29
29
|
clone.style.pointerEvents = 'none';
|
|
30
30
|
clone.style.userSelect = 'none';
|
|
31
|
+
// Visual only: out of the tab order and the accessibility tree, and its
|
|
32
|
+
// controls (copied with their inline handlers) can not be activated
|
|
33
|
+
clone.setAttribute('inert', '');
|
|
34
|
+
clone.setAttribute('aria-hidden', 'true');
|
|
31
35
|
|
|
32
36
|
// Pause/mute media
|
|
33
37
|
const mediaEls = clone.querySelectorAll('audio, video');
|
|
@@ -80,6 +84,18 @@ function cleanupSnapshots(containerEl: Element | null): void {
|
|
|
80
84
|
}
|
|
81
85
|
}
|
|
82
86
|
|
|
87
|
+
/**
|
|
88
|
+
* `config` for players who prefer reduced motion: the CSS shortens the
|
|
89
|
+
* animations (see styles.css), so the waits that follow them shrink too,
|
|
90
|
+
* leaving no empty passage area before the next passage.
|
|
91
|
+
*/
|
|
92
|
+
function reduceMotion(config: ResolvedTransition): ResolvedTransition {
|
|
93
|
+
if (!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) {
|
|
94
|
+
return config;
|
|
95
|
+
}
|
|
96
|
+
return { ...config, duration: Math.min(config.duration, 10), pause: 0 };
|
|
97
|
+
}
|
|
98
|
+
|
|
83
99
|
defineMacro({
|
|
84
100
|
name: 'passage',
|
|
85
101
|
interpolate: true,
|
|
@@ -173,10 +189,8 @@ defineMacro({
|
|
|
173
189
|
}
|
|
174
190
|
|
|
175
191
|
// Resolve transition from tags → nextTransition → store default → built-in
|
|
176
|
-
const config =
|
|
177
|
-
targetPassage.tags,
|
|
178
|
-
next,
|
|
179
|
-
transitionConfig,
|
|
192
|
+
const config = reduceMotion(
|
|
193
|
+
resolveTransition(targetPassage.tags, next, transitionConfig),
|
|
180
194
|
);
|
|
181
195
|
|
|
182
196
|
resolvedTypeRef.current = config.type;
|
|
@@ -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';
|
|
@@ -56,6 +57,7 @@ export function SaveManagerContent() {
|
|
|
56
57
|
} | null>(null);
|
|
57
58
|
const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set());
|
|
58
59
|
const [renamingId, setRenamingId] = useState<string | null>(null);
|
|
60
|
+
const renameCancelled = useRef(false);
|
|
59
61
|
const [renameValue, setRenameValue] = useState('');
|
|
60
62
|
const renameInputRef = useRef<HTMLInputElement>(null);
|
|
61
63
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
@@ -70,10 +72,15 @@ export function SaveManagerContent() {
|
|
|
70
72
|
'loadFromPayload',
|
|
71
73
|
);
|
|
72
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);
|
|
73
78
|
|
|
74
79
|
const refresh = useCallback(async () => {
|
|
75
80
|
if (!ifid) return;
|
|
76
81
|
const data = await getSavesGrouped(ifid);
|
|
82
|
+
// After the read: the storage in use is known by now
|
|
83
|
+
setTemporary(getBackendType() === 'memory');
|
|
77
84
|
setGroups(data);
|
|
78
85
|
setLoading(false);
|
|
79
86
|
}, [ifid]);
|
|
@@ -130,7 +137,7 @@ export function SaveManagerContent() {
|
|
|
130
137
|
// Queued now, in call order with other storage operations
|
|
131
138
|
return createSave(ifid, playthrough, payload);
|
|
132
139
|
});
|
|
133
|
-
showStatus('Save created');
|
|
140
|
+
showStatus(temporary ? 'Save created (temporary)' : 'Save created');
|
|
134
141
|
await refresh();
|
|
135
142
|
} catch {
|
|
136
143
|
showStatus('Failed to create save', 'error');
|
|
@@ -191,6 +198,7 @@ export function SaveManagerContent() {
|
|
|
191
198
|
};
|
|
192
199
|
|
|
193
200
|
const handleRenameStart = (save: SaveRecord) => {
|
|
201
|
+
renameCancelled.current = false;
|
|
194
202
|
setRenamingId(save.meta.id);
|
|
195
203
|
setRenameValue(save.meta.title);
|
|
196
204
|
};
|
|
@@ -209,7 +217,18 @@ export function SaveManagerContent() {
|
|
|
209
217
|
|
|
210
218
|
const handleRenameKeyDown = (e: KeyboardEvent) => {
|
|
211
219
|
if (e.key === 'Enter') handleRenameConfirm();
|
|
212
|
-
else if (e.key === 'Escape')
|
|
220
|
+
else if (e.key === 'Escape') {
|
|
221
|
+
// Cancel the edit only: the dialog stays open, and the blur that
|
|
222
|
+
// follows removing the input must not confirm it
|
|
223
|
+
e.preventDefault();
|
|
224
|
+
e.stopPropagation();
|
|
225
|
+
renameCancelled.current = true;
|
|
226
|
+
setRenamingId(null);
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
const handleRenameBlur = () => {
|
|
231
|
+
if (!renameCancelled.current) handleRenameConfirm();
|
|
213
232
|
};
|
|
214
233
|
|
|
215
234
|
const handleExport = async (saveId: string) => {
|
|
@@ -263,12 +282,14 @@ export function SaveManagerContent() {
|
|
|
263
282
|
<div class="saves-header-left">
|
|
264
283
|
<div class="saves-mode-toggle">
|
|
265
284
|
<button
|
|
285
|
+
type="button"
|
|
266
286
|
class={mode === 'save' ? 'active' : ''}
|
|
267
287
|
onClick={() => setMode('save')}
|
|
268
288
|
>
|
|
269
289
|
Save
|
|
270
290
|
</button>
|
|
271
291
|
<button
|
|
292
|
+
type="button"
|
|
272
293
|
class={mode === 'load' ? 'active' : ''}
|
|
273
294
|
onClick={() => setMode('load')}
|
|
274
295
|
>
|
|
@@ -278,8 +299,19 @@ export function SaveManagerContent() {
|
|
|
278
299
|
</div>
|
|
279
300
|
</div>
|
|
280
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
|
+
|
|
281
312
|
<div class="saves-toolbar">
|
|
282
313
|
<button
|
|
314
|
+
type="button"
|
|
283
315
|
class="saves-toolbar-button"
|
|
284
316
|
onClick={handleImport}
|
|
285
317
|
>
|
|
@@ -350,7 +382,7 @@ export function SaveManagerContent() {
|
|
|
350
382
|
)
|
|
351
383
|
}
|
|
352
384
|
onKeyDown={handleRenameKeyDown}
|
|
353
|
-
onBlur={
|
|
385
|
+
onBlur={handleRenameBlur}
|
|
354
386
|
/>
|
|
355
387
|
) : (
|
|
356
388
|
<div class="save-slot-title">{save.meta.title}</div>
|
|
@@ -363,6 +395,7 @@ export function SaveManagerContent() {
|
|
|
363
395
|
<div class="save-slot-actions">
|
|
364
396
|
{mode === 'save' ? (
|
|
365
397
|
<button
|
|
398
|
+
type="button"
|
|
366
399
|
class="save-slot-action primary"
|
|
367
400
|
onClick={() => handleOverwrite(save.meta.id)}
|
|
368
401
|
>
|
|
@@ -370,6 +403,7 @@ export function SaveManagerContent() {
|
|
|
370
403
|
</button>
|
|
371
404
|
) : (
|
|
372
405
|
<button
|
|
406
|
+
type="button"
|
|
373
407
|
class="save-slot-action primary"
|
|
374
408
|
onClick={() => handleLoad(save)}
|
|
375
409
|
>
|
|
@@ -377,18 +411,21 @@ export function SaveManagerContent() {
|
|
|
377
411
|
</button>
|
|
378
412
|
)}
|
|
379
413
|
<button
|
|
414
|
+
type="button"
|
|
380
415
|
class="save-slot-action"
|
|
381
416
|
onClick={() => handleRenameStart(save)}
|
|
382
417
|
>
|
|
383
418
|
Rename
|
|
384
419
|
</button>
|
|
385
420
|
<button
|
|
421
|
+
type="button"
|
|
386
422
|
class="save-slot-action"
|
|
387
423
|
onClick={() => handleExport(save.meta.id)}
|
|
388
424
|
>
|
|
389
425
|
Export
|
|
390
426
|
</button>
|
|
391
427
|
<button
|
|
428
|
+
type="button"
|
|
392
429
|
class="save-slot-action danger"
|
|
393
430
|
onClick={() => handleDelete(save.meta.id)}
|
|
394
431
|
>
|
|
@@ -400,6 +437,7 @@ export function SaveManagerContent() {
|
|
|
400
437
|
|
|
401
438
|
{mode === 'save' && isCurrentPt && (
|
|
402
439
|
<button
|
|
440
|
+
type="button"
|
|
403
441
|
class="save-slot-new"
|
|
404
442
|
onClick={handleNewSave}
|
|
405
443
|
>
|
|
@@ -419,6 +457,7 @@ export function SaveManagerContent() {
|
|
|
419
457
|
<div class="playthrough-group">
|
|
420
458
|
<div class="playthrough-saves">
|
|
421
459
|
<button
|
|
460
|
+
type="button"
|
|
422
461
|
class="save-slot-new"
|
|
423
462
|
onClick={handleNewSave}
|
|
424
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)}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { render } from 'preact';
|
|
2
|
-
import { useContext } from 'preact/hooks';
|
|
2
|
+
import { useContext, useEffect, useRef } from 'preact/hooks';
|
|
3
3
|
import {
|
|
4
4
|
renderNodes,
|
|
5
5
|
LocalsUpdateContext,
|
|
@@ -19,7 +19,10 @@ import { DialogCloseContext } from '../PassageDialog';
|
|
|
19
19
|
* Return a function that runs a macro body once, outside the passage tree:
|
|
20
20
|
* it renders `children` into a detached node, so the body's macros ({set},
|
|
21
21
|
* {if}, {goto}, ...) fire their side effects through the normal Preact
|
|
22
|
-
* pipeline
|
|
22
|
+
* pipeline. Used by {button} and {link} on click. The body stays mounted
|
|
23
|
+
* until the owning macro unmounts (its passage changes, say), so work it
|
|
24
|
+
* starts in an effect, such as the timer of a {timed} or {repeat}, can
|
|
25
|
+
* finish; the passage leaving cancels it.
|
|
23
26
|
*
|
|
24
27
|
* A detached render starts with no contexts, so the hook captures the ones
|
|
25
28
|
* the owning macro sees and provides them again: the locals scope (read
|
|
@@ -41,6 +44,16 @@ export function useDetachedBody(): (children: ASTNode[]) => void {
|
|
|
41
44
|
const repeat = useContext(RepeatContext);
|
|
42
45
|
const closeDialog = useContext(DialogCloseContext);
|
|
43
46
|
|
|
47
|
+
const mounted = useRef<HTMLElement[]>([]);
|
|
48
|
+
useEffect(
|
|
49
|
+
() => () => {
|
|
50
|
+
for (const container of mounted.current.splice(0)) {
|
|
51
|
+
render(null, container);
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
[],
|
|
55
|
+
);
|
|
56
|
+
|
|
44
57
|
const run = (children: ASTNode[]) => {
|
|
45
58
|
const locals = liveLocalsView(updater.getValues);
|
|
46
59
|
const container = document.createElement('div');
|
|
@@ -62,7 +75,7 @@ export function useDetachedBody(): (children: ASTNode[]) => void {
|
|
|
62
75
|
</LocalsUpdateContext.Provider>,
|
|
63
76
|
container,
|
|
64
77
|
);
|
|
65
|
-
|
|
78
|
+
mounted.current.push(container);
|
|
66
79
|
};
|
|
67
80
|
return (children) => runWithCommittedMutations(() => run(children));
|
|
68
81
|
}
|
package/src/expression.ts
CHANGED
|
@@ -123,32 +123,22 @@ export function historyQueries(
|
|
|
123
123
|
}
|
|
124
124
|
|
|
125
125
|
let cachedFns: ExpressionFns | null = null;
|
|
126
|
-
let cachedVisitCounts: Counts | null = null;
|
|
127
|
-
let cachedRenderCounts: Counts | null = null;
|
|
128
126
|
|
|
127
|
+
/**
|
|
128
|
+
* The functions compiled code calls. The history queries read the counters
|
|
129
|
+
* when called, not when the functions were built: mutation code that
|
|
130
|
+
* navigates (`Story.goto()`) goes on with the counters of the new state,
|
|
131
|
+
* as the Story API does.
|
|
132
|
+
*/
|
|
129
133
|
export function buildExpressionFns() {
|
|
130
|
-
|
|
131
|
-
const { visitCounts, renderCounts } = state;
|
|
132
|
-
|
|
133
|
-
if (
|
|
134
|
-
cachedFns &&
|
|
135
|
-
cachedVisitCounts === visitCounts &&
|
|
136
|
-
cachedRenderCounts === renderCounts
|
|
137
|
-
) {
|
|
138
|
-
return cachedFns;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
cachedFns = {
|
|
134
|
+
cachedFns ??= {
|
|
142
135
|
...historyQueries(
|
|
143
|
-
() => visitCounts,
|
|
144
|
-
() => renderCounts,
|
|
136
|
+
() => useStoryStore.getState().visitCounts,
|
|
137
|
+
() => useStoryStore.getState().renderCounts,
|
|
145
138
|
),
|
|
146
139
|
random,
|
|
147
140
|
randomInt,
|
|
148
141
|
};
|
|
149
|
-
cachedVisitCounts = visitCounts;
|
|
150
|
-
cachedRenderCounts = renderCounts;
|
|
151
|
-
|
|
152
142
|
return cachedFns;
|
|
153
143
|
}
|
|
154
144
|
|
|
@@ -221,8 +211,6 @@ export function clearExpressionCache(): void {
|
|
|
221
211
|
fnCache.clear();
|
|
222
212
|
clearTransformCache();
|
|
223
213
|
cachedFns = null;
|
|
224
|
-
cachedVisitCounts = null;
|
|
225
|
-
cachedRenderCounts = null;
|
|
226
214
|
}
|
|
227
215
|
|
|
228
216
|
export function evaluateWithState(expr: string, state: StoryState): unknown {
|