@rohal12/spindle 0.29.2 → 0.31.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rohal12/spindle",
3
- "version": "0.29.2",
3
+ "version": "0.31.0",
4
4
  "type": "module",
5
5
  "description": "A Preact-based story format for Twine 2.",
6
6
  "license": "Unlicense",
@@ -53,6 +53,15 @@ export function registerAction(action: StoryAction): () => void {
53
53
  };
54
54
  }
55
55
 
56
+ export function updateAction(action: StoryAction): () => void {
57
+ actions.set(action.id, action);
58
+ notify();
59
+ return () => {
60
+ actions.delete(action.id);
61
+ notify();
62
+ };
63
+ }
64
+
56
65
  export function getActions(): StoryAction[] {
57
66
  return Array.from(actions.values());
58
67
  }
@@ -49,6 +49,24 @@ export function deepClone<T>(value: T): T {
49
49
  return arr;
50
50
  }
51
51
 
52
+ if (val instanceof Map) {
53
+ const copy = new Map();
54
+ seen.set(obj, copy);
55
+ for (const [k, v] of val) {
56
+ copy.set(clone(k), clone(v));
57
+ }
58
+ return copy;
59
+ }
60
+
61
+ if (val instanceof Set) {
62
+ const copy = new Set();
63
+ seen.set(obj, copy);
64
+ for (const v of val) {
65
+ copy.add(clone(v));
66
+ }
67
+ return copy;
68
+ }
69
+
52
70
  // Registered class instance
53
71
  const ctor = obj.constructor as Constructor;
54
72
  const name = ctorToName.get(ctor);
@@ -119,6 +137,18 @@ export function serialize<T>(value: T): T {
119
137
  return result;
120
138
  }
121
139
 
140
+ if (val instanceof Map) {
141
+ const entries = [...val].map(([k, v]) => [ser(k), ser(v)]);
142
+ seen.delete(obj);
143
+ return { [CLASS_TAG]: '__Map__', [DATA_TAG]: { entries } };
144
+ }
145
+
146
+ if (val instanceof Set) {
147
+ const entries = [...val].map((v) => ser(v));
148
+ seen.delete(obj);
149
+ return { [CLASS_TAG]: '__Set__', [DATA_TAG]: { entries } };
150
+ }
151
+
122
152
  // Registered class instance
123
153
  const ctor = obj.constructor as Constructor;
124
154
  const name = ctorToName.get(ctor);
@@ -167,6 +197,14 @@ export function deserialize<T>(value: T): T {
167
197
  if (name === '__RegExp__') {
168
198
  return new RegExp(data.source as string, data.flags as string);
169
199
  }
200
+ if (name === '__Map__') {
201
+ const entries = data.entries as [unknown, unknown][];
202
+ return new Map(entries.map(([k, v]) => [deser(k), deser(v)]));
203
+ }
204
+ if (name === '__Set__') {
205
+ const entries = data.entries as unknown[];
206
+ return new Set(entries.map((v) => deser(v)));
207
+ }
170
208
 
171
209
  const ctor = registry.get(name);
172
210
  if (!ctor) {
@@ -1,5 +1,5 @@
1
1
  import { createContext } from 'preact';
2
- import { useMemo } from 'preact/hooks';
2
+ import { useCallback, 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';
@@ -22,6 +22,11 @@ export function PassageDialog({
22
22
  onClose,
23
23
  showCloseButton = true,
24
24
  }: PassageDialogProps) {
25
+ // Stabilize onClose so DialogCloseContext value doesn't change identity
26
+ const onCloseRef = useRef(onClose);
27
+ onCloseRef.current = onClose;
28
+ const stableOnClose = useCallback(() => onCloseRef.current(), []);
29
+
25
30
  const storyData = useStoryStore((s) => s.storyData);
26
31
 
27
32
  const passage = passageName
@@ -48,14 +53,14 @@ export function PassageDialog({
48
53
 
49
54
  const handleBackdrop = (e: MouseEvent) => {
50
55
  if ((e.target as HTMLElement).classList.contains('dialog-overlay')) {
51
- onClose();
56
+ stableOnClose();
52
57
  }
53
58
  };
54
59
 
55
60
  const cls = panelClass ? `dialog-panel ${panelClass}` : 'dialog-panel';
56
61
 
57
62
  return (
58
- <DialogCloseContext.Provider value={onClose}>
63
+ <DialogCloseContext.Provider value={stableOnClose}>
59
64
  <div
60
65
  class="dialog-overlay"
61
66
  onClick={handleBackdrop}
@@ -64,7 +69,7 @@ export function PassageDialog({
64
69
  {showCloseButton && (
65
70
  <button
66
71
  class="dialog-close"
67
- onClick={onClose}
72
+ onClick={stableOnClose}
68
73
  >
69
74
  ✕
70
75
  </button>
@@ -9,47 +9,58 @@ import {
9
9
  import type { QueuedDialog } from '../triggers';
10
10
 
11
11
  export function TriggerDialogHost() {
12
- const [current, setCurrent] = useState<QueuedDialog | null>(null);
13
- const currentRef = useRef(current);
14
- currentRef.current = current;
12
+ const [stack, setStack] = useState<QueuedDialog[]>([]);
13
+ const stackRef = useRef(stack);
14
+ stackRef.current = stack;
15
15
 
16
- const advance = useCallback(() => {
17
- const next = shiftDialogQueue();
18
- setCurrent(next ?? null);
16
+ const push = useCallback((item: QueuedDialog) => {
17
+ setStack((prev) => [...prev, item]);
19
18
  }, []);
20
19
 
21
20
  const handleClose = useCallback(() => {
22
- setCurrent(null);
23
- // Show next queued dialog after a tick
24
- if (dialogQueueLength() > 0) {
25
- requestAnimationFrame(advance);
26
- }
27
- }, [advance]);
21
+ setStack((prev) => prev.slice(0, -1));
22
+ }, []);
23
+
24
+ const handleCloseAll = useCallback(() => {
25
+ setStack([]);
26
+ }, []);
28
27
 
29
28
  useEffect(() => {
30
29
  return registerDialogHost({
31
30
  close: handleClose,
32
- isOpen: () => currentRef.current !== null,
31
+ closeAll: handleCloseAll,
32
+ push,
33
+ isOpen: () => stackRef.current.length > 0,
33
34
  });
34
- }, [handleClose]);
35
+ }, [handleClose, handleCloseAll, push]);
35
36
 
37
+ // Flush any dialogs that were queued before the host mounted
36
38
  useEffect(() => {
37
39
  return subscribeTriggerDialogs(() => {
38
- setCurrent((prev) => {
39
- if (prev !== null) return prev; // already showing one
40
- return shiftDialogQueue() ?? null;
41
- });
40
+ const items: QueuedDialog[] = [];
41
+ while (dialogQueueLength() > 0) {
42
+ const next = shiftDialogQueue();
43
+ if (next) items.push(next);
44
+ }
45
+ if (items.length > 0) {
46
+ setStack((prev) => [...prev, ...items]);
47
+ }
42
48
  });
43
49
  }, []);
44
50
 
45
- if (!current) return null;
51
+ if (stack.length === 0) return null;
46
52
 
47
53
  return (
48
- <PassageDialog
49
- passageName={current.passageName}
50
- panelClass={current.panelClass}
51
- onClose={handleClose}
52
- showCloseButton={current.showCloseButton}
53
- />
54
+ <>
55
+ {stack.map((dialog, i) => (
56
+ <PassageDialog
57
+ key={`${dialog.passageName}-${i}`}
58
+ passageName={dialog.passageName}
59
+ panelClass={dialog.panelClass}
60
+ onClose={handleClose}
61
+ showCloseButton={dialog.showCloseButton}
62
+ />
63
+ ))}
64
+ </>
54
65
  );
55
66
  }
@@ -15,9 +15,11 @@ defineMacro({
15
15
  state.deleteVariable(name.slice(1));
16
16
  } else if (name.startsWith('_')) {
17
17
  state.deleteTemporary(name.slice(1));
18
+ } else if (name.startsWith('@')) {
19
+ ctx.update(name.slice(1), undefined);
18
20
  } else {
19
21
  console.error(
20
- `spindle: {unset} expects a variable ($name or _name), got "${name}"`,
22
+ `spindle: {unset} expects a variable ($name, _name, or @name), got "${name}"`,
21
23
  );
22
24
  }
23
25
  }
@@ -130,7 +130,11 @@ export function defineMacro(config: MacroDefinition): void {
130
130
  mutate: (code: string) => executeMutation(code, getValues(), update),
131
131
  update,
132
132
  getValues,
133
- wrap: undefined as any,
133
+ wrap: (content: ComponentChildren): VNode<any> => {
134
+ if (className || id)
135
+ return h('span', { id, class: className }, content);
136
+ return h(Fragment, null, content);
137
+ },
134
138
  };
135
139
 
136
140
  if (config.merged) {
@@ -149,12 +153,6 @@ export function defineMacro(config: MacroDefinition): void {
149
153
  ctx.setValue = (value: unknown) => setVariable(varName, value);
150
154
  }
151
155
 
152
- ctx.wrap = (content: ComponentChildren): VNode<any> => {
153
- if (ctx.className || ctx.id)
154
- return h('span', { id: ctx.id, class: ctx.className }, content);
155
- return h(Fragment, null, content);
156
- };
157
-
158
156
  return config.render(props, ctx);
159
157
  }
160
158
 
package/src/expression.ts CHANGED
@@ -323,6 +323,14 @@ export function execute(
323
323
  /**
324
324
  * Convenience: evaluate using store state directly.
325
325
  */
326
+ /** Clear the compiled expression cache. Useful for testing and HMR. */
327
+ export function clearExpressionCache(): void {
328
+ fnCache.clear();
329
+ cachedFns = null;
330
+ cachedVisitCounts = null;
331
+ cachedRenderCounts = null;
332
+ }
333
+
326
334
  export function evaluateWithState(expr: string, state: StoryState): unknown {
327
335
  return evaluate(expr, state.variables, state.temporary, {});
328
336
  }
@@ -21,6 +21,8 @@ export interface UseActionOptions {
21
21
 
22
22
  export function useAction(opts: UseActionOptions): string {
23
23
  const idRef = useRef<string>('');
24
+ const performRef = useRef(opts.perform);
25
+ performRef.current = opts.perform;
24
26
 
25
27
  // Generate ID only once on first call
26
28
  if (!idRef.current) {
@@ -34,7 +36,7 @@ export function useAction(opts: UseActionOptions): string {
34
36
  id,
35
37
  type: opts.type,
36
38
  label: opts.label,
37
- perform: opts.perform,
39
+ perform: (...args) => performRef.current(...args),
38
40
  };
39
41
  if (opts.target !== undefined) action.target = opts.target;
40
42
  if (opts.variable !== undefined) action.variable = opts.variable;
@@ -43,7 +45,16 @@ export function useAction(opts: UseActionOptions): string {
43
45
  if (opts.disabled !== undefined) action.disabled = opts.disabled;
44
46
 
45
47
  return registerAction(action);
46
- });
48
+ }, [
49
+ id,
50
+ opts.type,
51
+ opts.label,
52
+ opts.target,
53
+ opts.variable,
54
+ opts.disabled,
55
+ opts.value,
56
+ JSON.stringify(opts.options),
57
+ ]);
47
58
 
48
59
  return id;
49
60
  }
package/src/story-api.ts CHANGED
@@ -25,8 +25,8 @@ import {
25
25
  addTrigger,
26
26
  removeTrigger,
27
27
  pushDialog,
28
- clearDialogQueue,
29
28
  closeCurrentDialog,
29
+ closeAllOpenDialogs,
30
30
  isDialogShowing,
31
31
  } from './triggers';
32
32
  import type { WatchOptions } from './triggers';
@@ -316,8 +316,7 @@ function createStoryAPI(): StoryAPI {
316
316
  },
317
317
 
318
318
  closeAllDialogs(): void {
319
- clearDialogQueue();
320
- closeCurrentDialog();
319
+ closeAllOpenDialogs();
321
320
  },
322
321
 
323
322
  isDialogOpen(): boolean {
@@ -383,6 +382,12 @@ function createStoryAPI(): StoryAPI {
383
382
  };
384
383
  }
385
384
 
385
+ declare global {
386
+ interface Window {
387
+ Story: StoryAPI;
388
+ }
389
+ }
390
+
386
391
  export function installStoryAPI(): void {
387
- (window as any).Story = createStoryAPI();
392
+ window.Story = createStoryAPI();
388
393
  }
package/src/triggers.ts CHANGED
@@ -36,6 +36,8 @@ let dialogNotify: DialogCallback | null = null;
36
36
 
37
37
  interface DialogHostCallbacks {
38
38
  close: () => void;
39
+ closeAll: () => void;
40
+ push: (item: QueuedDialog) => void;
39
41
  isOpen: () => boolean;
40
42
  }
41
43
  let dialogHostCallbacks: DialogHostCallbacks | null = null;
@@ -153,7 +155,7 @@ export function resetTriggers(): void {
153
155
  triggers = [];
154
156
  dialogQueue = [];
155
157
  nextId = 0;
156
- dialogHostCallbacks?.close();
158
+ dialogHostCallbacks?.closeAll();
157
159
  }
158
160
 
159
161
  export function subscribeTriggerDialogs(cb: () => void): () => void {
@@ -174,8 +176,12 @@ export function dialogQueueLength(): number {
174
176
  }
175
177
 
176
178
  export function pushDialog(item: QueuedDialog): void {
177
- dialogQueue.push(item);
178
- dialogNotify?.();
179
+ if (dialogHostCallbacks) {
180
+ dialogHostCallbacks.push(item);
181
+ } else {
182
+ dialogQueue.push(item);
183
+ dialogNotify?.();
184
+ }
179
185
  }
180
186
 
181
187
  export function clearDialogQueue(): void {
@@ -193,6 +199,11 @@ export function closeCurrentDialog(): void {
193
199
  dialogHostCallbacks?.close();
194
200
  }
195
201
 
202
+ export function closeAllOpenDialogs(): void {
203
+ clearDialogQueue();
204
+ dialogHostCallbacks?.closeAll();
205
+ }
206
+
196
207
  export function isDialogShowing(): boolean {
197
208
  return dialogHostCallbacks?.isOpen() ?? false;
198
209
  }