@rohal12/spindle 0.46.0 → 0.48.0
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/package.json +1 -1
- package/src/components/Passage.tsx +19 -1
- package/src/components/PassageDialog.tsx +14 -2
- package/src/event-emitter.ts +24 -0
- package/src/index.tsx +6 -1
- package/src/story-api.ts +55 -4
- package/types/index.d.ts +12 -0
package/package.json
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
useMemo,
|
|
3
|
+
useEffect,
|
|
4
|
+
useLayoutEffect,
|
|
5
|
+
useRef,
|
|
6
|
+
useState,
|
|
7
|
+
} from 'preact/hooks';
|
|
2
8
|
import { tokenize } from '../markup/tokenizer';
|
|
3
9
|
import { buildAST } from '../markup/ast';
|
|
4
10
|
import { renderNodes, NobrContext } from '../markup/render';
|
|
5
11
|
import { useStoryStore } from '../store';
|
|
6
12
|
import type { Passage as PassageData } from '../parser';
|
|
7
13
|
import { sourceLocationOf } from '../utils/source-location';
|
|
14
|
+
import { emitFromRender } from '../event-emitter';
|
|
8
15
|
|
|
9
16
|
export function renderPassageContent(passage: PassageData) {
|
|
10
17
|
const tokens = tokenize(passage.content);
|
|
@@ -90,10 +97,21 @@ export function Passage({ passage, dataTransition }: PassageProps) {
|
|
|
90
97
|
}
|
|
91
98
|
}, [doneReady, donePassage?.content]);
|
|
92
99
|
|
|
100
|
+
// Signal that this passage's DOM is committed (after descendants' layout
|
|
101
|
+
// effects, before paint). Runs once per mount: PassageDisplay keys the
|
|
102
|
+
// Passage so every navigation that shows a passage mounts a new one.
|
|
103
|
+
const elRef = useRef<HTMLDivElement>(null);
|
|
104
|
+
useLayoutEffect(() => {
|
|
105
|
+
if (elRef.current) {
|
|
106
|
+
emitFromRender('passagerender', passage.name, elRef.current);
|
|
107
|
+
}
|
|
108
|
+
}, [passage.name]);
|
|
109
|
+
|
|
93
110
|
const nobr = passage.tags.includes('nobr');
|
|
94
111
|
|
|
95
112
|
const inner = (
|
|
96
113
|
<div
|
|
114
|
+
ref={elRef}
|
|
97
115
|
class="passage"
|
|
98
116
|
data-passage={passage.name}
|
|
99
117
|
data-tags={passage.tags.join(' ')}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { createContext } from 'preact';
|
|
2
|
-
import { useCallback, useMemo, useRef } from 'preact/hooks';
|
|
2
|
+
import { useCallback, useLayoutEffect, useMemo, useRef } from 'preact/hooks';
|
|
3
3
|
import { tokenize } from '../markup/tokenizer';
|
|
4
4
|
import { buildAST } from '../markup/ast';
|
|
5
5
|
import { renderNodes } from '../markup/render';
|
|
6
6
|
import { useStoryStore } from '../store';
|
|
7
|
+
import { emitFromRender } from '../event-emitter';
|
|
7
8
|
|
|
8
9
|
export const DialogCloseContext = createContext<(() => void) | null>(null);
|
|
9
10
|
|
|
@@ -59,6 +60,14 @@ export function PassageDialog({
|
|
|
59
60
|
}
|
|
60
61
|
}, [markup]);
|
|
61
62
|
|
|
63
|
+
// Signal that the dialog's DOM is committed (once per open).
|
|
64
|
+
const panelRef = useRef<HTMLDivElement>(null);
|
|
65
|
+
useLayoutEffect(() => {
|
|
66
|
+
if (panelRef.current) {
|
|
67
|
+
emitFromRender('dialogrender', passageName ?? '', panelRef.current);
|
|
68
|
+
}
|
|
69
|
+
}, []);
|
|
70
|
+
|
|
62
71
|
const handleBackdrop = (e: MouseEvent) => {
|
|
63
72
|
if (!dismissible) return;
|
|
64
73
|
if ((e.target as HTMLElement).classList.contains('dialog-overlay')) {
|
|
@@ -74,7 +83,10 @@ export function PassageDialog({
|
|
|
74
83
|
class="dialog-overlay"
|
|
75
84
|
onClick={handleBackdrop}
|
|
76
85
|
>
|
|
77
|
-
<div
|
|
86
|
+
<div
|
|
87
|
+
ref={panelRef}
|
|
88
|
+
class={cls}
|
|
89
|
+
>
|
|
78
90
|
{showCloseButton && (
|
|
79
91
|
<button
|
|
80
92
|
class="dialog-close"
|
package/src/event-emitter.ts
CHANGED
|
@@ -14,6 +14,8 @@ type EventMap = {
|
|
|
14
14
|
afterload: (slot: string | undefined) => void;
|
|
15
15
|
beforenavigate: (passageName: string) => void;
|
|
16
16
|
afternavigate: (to: string, from: string) => void;
|
|
17
|
+
passagerender: (passage: string, element: HTMLElement) => void;
|
|
18
|
+
dialogrender: (passage: string, element: HTMLElement) => void;
|
|
17
19
|
};
|
|
18
20
|
|
|
19
21
|
export type StoryEvent = keyof EventMap;
|
|
@@ -30,6 +32,8 @@ const VALID_EVENTS = new Set<string>([
|
|
|
30
32
|
'afterload',
|
|
31
33
|
'beforenavigate',
|
|
32
34
|
'afternavigate',
|
|
35
|
+
'passagerender',
|
|
36
|
+
'dialogrender',
|
|
33
37
|
]);
|
|
34
38
|
|
|
35
39
|
// Each event key maps to a Set of callbacks.
|
|
@@ -65,6 +69,26 @@ export function emit<E extends StoryEvent>(
|
|
|
65
69
|
}
|
|
66
70
|
}
|
|
67
71
|
|
|
72
|
+
/**
|
|
73
|
+
* Emit an event from inside a render commit (layout effect). A throwing
|
|
74
|
+
* handler is logged instead of propagating into Preact and breaking the
|
|
75
|
+
* render; the remaining handlers still run.
|
|
76
|
+
*/
|
|
77
|
+
export function emitFromRender<E extends StoryEvent>(
|
|
78
|
+
event: E,
|
|
79
|
+
...args: Parameters<EventMap[E]>
|
|
80
|
+
): void {
|
|
81
|
+
const set = listeners.get(event);
|
|
82
|
+
if (!set) return;
|
|
83
|
+
for (const cb of [...set]) {
|
|
84
|
+
try {
|
|
85
|
+
(cb as Function)(...args);
|
|
86
|
+
} catch (err) {
|
|
87
|
+
console.error(`spindle: Error in ${event} handler:`, err);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
68
92
|
/** Test-only: clear all listeners. */
|
|
69
93
|
export function resetEmitter(): void {
|
|
70
94
|
listeners = new Map();
|
package/src/index.tsx
CHANGED
|
@@ -3,7 +3,11 @@ import { App } from './components/App';
|
|
|
3
3
|
import { parseStoryData } from './parser';
|
|
4
4
|
import { useStoryStore, enterRuntimePhase } from './store';
|
|
5
5
|
import { emit } from './event-emitter';
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
installStoryAPI,
|
|
8
|
+
getReadyPromise,
|
|
9
|
+
setDeclaredVariables,
|
|
10
|
+
} from './story-api';
|
|
7
11
|
import { resetIdCounters } from './action-registry';
|
|
8
12
|
import { executeStoryInit } from './story-init';
|
|
9
13
|
import { checkTriggers, reinitTriggerState } from './triggers';
|
|
@@ -118,6 +122,7 @@ function boot() {
|
|
|
118
122
|
}
|
|
119
123
|
|
|
120
124
|
defaults = extractDefaults(schema);
|
|
125
|
+
setDeclaredVariables(Object.keys(defaults), Object.keys(transientDefaults));
|
|
121
126
|
|
|
122
127
|
useStoryStore.getState().init(storyData, defaults, transientDefaults);
|
|
123
128
|
|
package/src/story-api.ts
CHANGED
|
@@ -232,11 +232,63 @@ export interface StoryAPI {
|
|
|
232
232
|
};
|
|
233
233
|
}
|
|
234
234
|
|
|
235
|
+
// Names declared in StoryVariables / StoryTransients, registered at boot.
|
|
236
|
+
// null until a schema is registered (e.g. unit tests that init the store
|
|
237
|
+
// directly), in which case Story.set() does not check names.
|
|
238
|
+
let declaredVariables: ReadonlySet<string> | null = null;
|
|
239
|
+
let declaredTransients: ReadonlySet<string> | null = null;
|
|
240
|
+
const warnedUndeclared = new Set<string>();
|
|
241
|
+
|
|
242
|
+
/** Register the declared variable names so Story.set() can flag typos. */
|
|
243
|
+
export function setDeclaredVariables(
|
|
244
|
+
variables: Iterable<string>,
|
|
245
|
+
transients: Iterable<string> = [],
|
|
246
|
+
): void {
|
|
247
|
+
declaredVariables = new Set(variables);
|
|
248
|
+
declaredTransients = new Set(transients);
|
|
249
|
+
warnedUndeclared.clear();
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Test-only: forget the registered declarations. */
|
|
253
|
+
export function _resetDeclaredVariables(): void {
|
|
254
|
+
declaredVariables = null;
|
|
255
|
+
declaredTransients = null;
|
|
256
|
+
warnedUndeclared.clear();
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Split an API variable name into namespace and key. Accepts the bare name
|
|
261
|
+
* (`hp`), the `$` sigil authors use in passages (`$hp`), and `%` for
|
|
262
|
+
* transients (`%npcs`). Dot-paths are kept in the key.
|
|
263
|
+
*/
|
|
264
|
+
function parseName(name: string): {
|
|
265
|
+
isTransient: boolean;
|
|
266
|
+
key: string;
|
|
267
|
+
} {
|
|
268
|
+
if (name.startsWith('%')) return { isTransient: true, key: name.slice(1) };
|
|
269
|
+
if (name.startsWith('$')) return { isTransient: false, key: name.slice(1) };
|
|
270
|
+
return { isTransient: false, key: name };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function warnIfUndeclared(isTransient: boolean, key: string): void {
|
|
274
|
+
const declared = isTransient ? declaredTransients : declaredVariables;
|
|
275
|
+
if (!declared) return;
|
|
276
|
+
const root = key.split('.')[0]!;
|
|
277
|
+
if (declared.has(root)) return;
|
|
278
|
+
const label = (isTransient ? '%' : '$') + root;
|
|
279
|
+
if (warnedUndeclared.has(label)) return;
|
|
280
|
+
warnedUndeclared.add(label);
|
|
281
|
+
const where = isTransient ? 'StoryTransients' : 'StoryVariables';
|
|
282
|
+
console.warn(
|
|
283
|
+
`spindle: Story.set() wrote ${label}, which is not declared in ${where}. Passages cannot reference it; check the name or declare it.`,
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
235
287
|
/** Set a single variable, resolving dot-paths if present. */
|
|
236
288
|
function setOne(name: string, value: unknown): void {
|
|
237
|
-
const isTransient = name
|
|
238
|
-
const key = isTransient ? name.slice(1) : name;
|
|
289
|
+
const { isTransient, key } = parseName(name);
|
|
239
290
|
const namespace = isTransient ? 'transient' : 'variables';
|
|
291
|
+
warnIfUndeclared(isTransient, key);
|
|
240
292
|
|
|
241
293
|
if (key.includes('.')) {
|
|
242
294
|
useStoryStore.setState((state) => {
|
|
@@ -255,8 +307,7 @@ function setOne(name: string, value: unknown): void {
|
|
|
255
307
|
function createStoryAPI(): StoryAPI {
|
|
256
308
|
return {
|
|
257
309
|
get(name: string): unknown {
|
|
258
|
-
const isTransient = name
|
|
259
|
-
const key = isTransient ? name.slice(1) : name;
|
|
310
|
+
const { isTransient, key } = parseName(name);
|
|
260
311
|
const store = isTransient
|
|
261
312
|
? useStoryStore.getState().transient
|
|
262
313
|
: useStoryStore.getState().variables;
|
package/types/index.d.ts
CHANGED
|
@@ -138,6 +138,18 @@ export interface StoryEventMap {
|
|
|
138
138
|
afterload: (slot: string | undefined) => void;
|
|
139
139
|
beforenavigate: (passageName: string) => void;
|
|
140
140
|
afternavigate: (to: string, from: string) => void;
|
|
141
|
+
/**
|
|
142
|
+
* A passage's `.passage` element was committed to the DOM (first render,
|
|
143
|
+
* navigation, back/forward, restart, load). Fires after the transition
|
|
144
|
+
* mounts the new passage, before paint.
|
|
145
|
+
*/
|
|
146
|
+
passagerender: (passage: string, element: HTMLElement) => void;
|
|
147
|
+
/**
|
|
148
|
+
* A dialog was opened and its `.dialog-panel` element committed to the DOM.
|
|
149
|
+
* `passage` is the dialog's passage name (empty for built-in dialogs
|
|
150
|
+
* without one).
|
|
151
|
+
*/
|
|
152
|
+
dialogrender: (passage: string, element: HTMLElement) => void;
|
|
141
153
|
}
|
|
142
154
|
|
|
143
155
|
/** Event name that can be passed to `Story.on()`. */
|