@rohal12/spindle 0.30.0 → 0.31.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rohal12/spindle",
3
- "version": "0.30.0",
3
+ "version": "0.31.1",
4
4
  "type": "module",
5
5
  "description": "A Preact-based story format for Twine 2.",
6
6
  "license": "Unlicense",
@@ -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
  }
@@ -1,7 +1,7 @@
1
1
  import { defineMacro } from '../../define-macro';
2
2
 
3
3
  function parseLabel(rawArgs: string): string {
4
- const match = rawArgs.match(/^\s*["']?\$?\w+["']?\s+["']?(.+?)["']?\s*$/);
4
+ const match = rawArgs.match(/^\s*["']?\$?[\w.]+["']?\s+["']?(.+?)["']?\s*$/);
5
5
  return match?.[1] ?? '';
6
6
  }
7
7
 
@@ -1,4 +1,3 @@
1
- import { useStoryStore } from '../../store';
2
1
  import { defineMacro } from '../../define-macro';
3
2
 
4
3
  defineMacro({
@@ -9,7 +8,7 @@ defineMacro({
9
8
 
10
9
  const handleClick = () => {
11
10
  if (options.length === 0) return;
12
- const current = useStoryStore.getState().variables[ctx.varName!];
11
+ const current = ctx.getValue!();
13
12
  const currentIndex = options.indexOf(String(current));
14
13
  const nextIndex = (currentIndex + 1) % options.length;
15
14
  ctx.setValue!(options[nextIndex]);
@@ -2,7 +2,7 @@ import { defineMacro } from '../../define-macro';
2
2
 
3
3
  function parseRadioArgs(rawArgs: string): { value: string; label: string } {
4
4
  const match = rawArgs.match(
5
- /^\s*["']?\$?\w+["']?\s+["'](.+?)["']\s+["']?(.+?)["']?\s*$/,
5
+ /^\s*["']?\$?[\w.]+["']?\s+["'](.+?)["']\s+["']?(.+?)["']?\s*$/,
6
6
  );
7
7
  if (!match) {
8
8
  const parts = rawArgs.trim().split(/\s+/).slice(1);
@@ -4,7 +4,9 @@ export function parseVarArgs(rawArgs: string): {
4
4
  varName: string;
5
5
  placeholder: string;
6
6
  } {
7
- const match = rawArgs.match(/^\s*(["']?\$\w+["']?)\s*(?:["'](.*)["'])?\s*$/);
7
+ const match = rawArgs.match(
8
+ /^\s*(["']?\$[\w.]+["']?)\s*(?:["'](.*)["'])?\s*$/,
9
+ );
8
10
  if (!match) {
9
11
  return { varName: rawArgs.trim(), placeholder: '' };
10
12
  }
@@ -52,6 +52,7 @@ export interface MacroContext {
52
52
  varName?: string;
53
53
  value?: unknown;
54
54
  setValue?: (value: unknown) => void;
55
+ getValue?: () => unknown;
55
56
  evaluate?: (expr: string) => unknown;
56
57
  collectText: typeof collectText;
57
58
  sourceLocation: typeof currentSourceLocation;
@@ -93,6 +94,33 @@ const sharedHooks = {
93
94
  useContext,
94
95
  };
95
96
 
97
+ /** Traverse a dot-path on an object, returning the nested value. */
98
+ function getByPath(obj: Record<string, unknown>, segments: string[]): unknown {
99
+ let current: unknown = obj;
100
+ for (const seg of segments) {
101
+ if (current == null || typeof current !== 'object') return undefined;
102
+ current = (current as Record<string, unknown>)[seg];
103
+ }
104
+ return current;
105
+ }
106
+
107
+ /** Set a nested value by dot-path segments on an (Immer draft) object. */
108
+ function setByPath(
109
+ obj: Record<string, unknown>,
110
+ segments: string[],
111
+ value: unknown,
112
+ ): void {
113
+ let target: Record<string, unknown> = obj;
114
+ for (let i = 0; i < segments.length - 1; i++) {
115
+ const seg = segments[i]!;
116
+ if (target[seg] == null || typeof target[seg] !== 'object') {
117
+ target[seg] = {};
118
+ }
119
+ target = target[seg] as Record<string, unknown>;
120
+ }
121
+ target[segments[segments.length - 1]!] = value;
122
+ }
123
+
96
124
  export function defineMacro(config: MacroDefinition): void {
97
125
  function Wrapper(props: MacroProps) {
98
126
  // className/id resolved first (interpolate may transform them)
@@ -146,11 +174,21 @@ export function defineMacro(config: MacroDefinition): void {
146
174
 
147
175
  if (config.storeVar) {
148
176
  const firstToken = props.rawArgs.trim().split(/[\s"']+/)[0] ?? '';
149
- const varName = firstToken.replace(/["']/g, '').replace(/^\$/, '');
150
- ctx.varName = varName;
151
- ctx.value = useStoryStore((s) => s.variables[varName]);
152
- const setVariable = useStoryStore((s) => s.setVariable);
153
- ctx.setValue = (value: unknown) => setVariable(varName, value);
177
+ const varExpr = firstToken.replace(/["']/g, '').replace(/^\$/, '');
178
+ const segments = varExpr.split('.');
179
+ ctx.varName = varExpr;
180
+ ctx.value = useStoryStore((s) => getByPath(s.variables, segments));
181
+ ctx.getValue = () =>
182
+ getByPath(useStoryStore.getState().variables, segments);
183
+ ctx.setValue = (value: unknown) => {
184
+ useStoryStore.setState((state) => {
185
+ setByPath(
186
+ state.variables as Record<string, unknown>,
187
+ segments,
188
+ value,
189
+ );
190
+ });
191
+ };
154
192
  }
155
193
 
156
194
  return config.render(props, ctx);
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 {
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
  }