@rohal12/spindle 0.17.0 → 0.19.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 +2 -2
- package/src/components/App.tsx +7 -1
- package/src/components/Passage.tsx +69 -4
- package/src/components/StoryInterface.tsx +2 -2
- package/src/components/TriggerDialogHost.tsx +42 -0
- package/src/components/macros/Button.tsx +25 -10
- package/src/components/macros/Dialog.tsx +6 -12
- package/src/components/macros/MacroLink.tsx +26 -38
- package/src/components/macros/PassageDisplay.tsx +11 -1
- package/src/components/macros/Span.tsx +8 -0
- package/src/components/macros/Timed.tsx +6 -4
- package/src/components/macros/Watch.tsx +75 -0
- package/src/expression.ts +10 -8
- package/src/index.tsx +23 -0
- package/src/macros/register-builtins.ts +2 -0
- package/src/markup/ast.ts +1 -0
- package/src/saves/save-manager.ts +43 -0
- package/src/saves/types.ts +9 -2
- package/src/store.ts +285 -52
- package/src/story-api.ts +44 -12
- package/src/story-init.ts +15 -48
- package/src/triggers.ts +161 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rohal12/spindle",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "A Preact-based story format for Twine 2.",
|
|
6
6
|
"license": "Unlicense",
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
"docs:dev": "vitepress dev docs",
|
|
48
48
|
"docs:build": "vitepress build docs",
|
|
49
49
|
"docs:preview": "vitepress preview docs",
|
|
50
|
-
"prepublishOnly": "bun run
|
|
50
|
+
"prepublishOnly": "bun run build && bun run test",
|
|
51
51
|
"prepare": "husky"
|
|
52
52
|
},
|
|
53
53
|
"dependencies": {
|
package/src/components/App.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useEffect } from 'preact/hooks';
|
|
2
2
|
import { useStoryStore } from '../store';
|
|
3
3
|
import { StoryInterface } from './StoryInterface';
|
|
4
|
+
import { TriggerDialogHost } from './TriggerDialogHost';
|
|
4
5
|
|
|
5
6
|
export function App() {
|
|
6
7
|
const storyData = useStoryStore((s) => s.storyData);
|
|
@@ -24,5 +25,10 @@ export function App() {
|
|
|
24
25
|
return <div class="loading">Loading...</div>;
|
|
25
26
|
}
|
|
26
27
|
|
|
27
|
-
return
|
|
28
|
+
return (
|
|
29
|
+
<>
|
|
30
|
+
<StoryInterface />
|
|
31
|
+
<TriggerDialogHost />
|
|
32
|
+
</>
|
|
33
|
+
);
|
|
28
34
|
}
|
|
@@ -1,20 +1,36 @@
|
|
|
1
|
-
import { useMemo } from 'preact/hooks';
|
|
1
|
+
import { useMemo, useEffect, useState } from 'preact/hooks';
|
|
2
2
|
import { tokenize } from '../markup/tokenizer';
|
|
3
3
|
import { buildAST } from '../markup/ast';
|
|
4
4
|
import { renderNodes } from '../markup/render';
|
|
5
|
+
import { useStoryStore } from '../store';
|
|
5
6
|
import type { Passage as PassageData } from '../parser';
|
|
6
7
|
import { sourceLocationOf } from '../utils/source-location';
|
|
7
8
|
|
|
9
|
+
export function renderPassageContent(passage: PassageData) {
|
|
10
|
+
const tokens = tokenize(passage.content);
|
|
11
|
+
const ast = buildAST(tokens);
|
|
12
|
+
return renderNodes(ast);
|
|
13
|
+
}
|
|
14
|
+
|
|
8
15
|
interface PassageProps {
|
|
9
16
|
passage: PassageData;
|
|
10
17
|
}
|
|
11
18
|
|
|
19
|
+
const CODE_PASSAGES = new Set([
|
|
20
|
+
'PassageReady',
|
|
21
|
+
'PassageHeader',
|
|
22
|
+
'PassageFooter',
|
|
23
|
+
'PassageDone',
|
|
24
|
+
]);
|
|
25
|
+
|
|
12
26
|
export function Passage({ passage }: PassageProps) {
|
|
27
|
+
const storyData = useStoryStore((s) => s.storyData);
|
|
28
|
+
const isCodePassage = CODE_PASSAGES.has(passage.name);
|
|
29
|
+
const [doneReady, setDoneReady] = useState(false);
|
|
30
|
+
|
|
13
31
|
const content = useMemo(() => {
|
|
14
32
|
try {
|
|
15
|
-
|
|
16
|
-
const ast = buildAST(tokens);
|
|
17
|
-
return renderNodes(ast);
|
|
33
|
+
return renderPassageContent(passage);
|
|
18
34
|
} catch (err) {
|
|
19
35
|
return (
|
|
20
36
|
<div class="error">
|
|
@@ -25,13 +41,62 @@ export function Passage({ passage }: PassageProps) {
|
|
|
25
41
|
}
|
|
26
42
|
}, [passage.content, passage.name]);
|
|
27
43
|
|
|
44
|
+
const headerPassage = isCodePassage
|
|
45
|
+
? undefined
|
|
46
|
+
: storyData?.passages.get('PassageHeader');
|
|
47
|
+
const footerPassage = isCodePassage
|
|
48
|
+
? undefined
|
|
49
|
+
: storyData?.passages.get('PassageFooter');
|
|
50
|
+
const donePassage = isCodePassage
|
|
51
|
+
? undefined
|
|
52
|
+
: storyData?.passages.get('PassageDone');
|
|
53
|
+
|
|
54
|
+
const headerContent = useMemo(() => {
|
|
55
|
+
if (!headerPassage) return null;
|
|
56
|
+
try {
|
|
57
|
+
return renderPassageContent(headerPassage);
|
|
58
|
+
} catch (err) {
|
|
59
|
+
console.error('spindle: Error in PassageHeader:', err);
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}, [headerPassage?.content]);
|
|
63
|
+
|
|
64
|
+
const footerContent = useMemo(() => {
|
|
65
|
+
if (!footerPassage) return null;
|
|
66
|
+
try {
|
|
67
|
+
return renderPassageContent(footerPassage);
|
|
68
|
+
} catch (err) {
|
|
69
|
+
console.error('spindle: Error in PassageFooter:', err);
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
}, [footerPassage?.content]);
|
|
73
|
+
|
|
74
|
+
// Defer PassageDone to after DOM commit
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
if (donePassage) setDoneReady(true);
|
|
77
|
+
return () => setDoneReady(false);
|
|
78
|
+
}, [passage.name]);
|
|
79
|
+
|
|
80
|
+
const doneContent = useMemo(() => {
|
|
81
|
+
if (!doneReady || !donePassage) return null;
|
|
82
|
+
try {
|
|
83
|
+
return renderPassageContent(donePassage);
|
|
84
|
+
} catch (err) {
|
|
85
|
+
console.error('spindle: Error in PassageDone:', err);
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}, [doneReady, donePassage?.content]);
|
|
89
|
+
|
|
28
90
|
return (
|
|
29
91
|
<div
|
|
30
92
|
class="passage"
|
|
31
93
|
data-passage={passage.name}
|
|
32
94
|
data-tags={passage.tags.join(' ')}
|
|
33
95
|
>
|
|
96
|
+
{headerContent && <div class="passage-header">{headerContent}</div>}
|
|
34
97
|
{content}
|
|
98
|
+
{footerContent && <div class="passage-footer">{footerContent}</div>}
|
|
99
|
+
{doneContent && <div hidden>{doneContent}</div>}
|
|
35
100
|
</div>
|
|
36
101
|
);
|
|
37
102
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useStoryStore } from '../store';
|
|
2
2
|
import { tokenize } from '../markup/tokenizer';
|
|
3
3
|
import { buildAST } from '../markup/ast';
|
|
4
|
-
import {
|
|
4
|
+
import { renderInlineNodes } from '../markup/render';
|
|
5
5
|
|
|
6
6
|
const DEFAULT_MARKUP =
|
|
7
7
|
'<header class="story-menubar">{story-title}{back}{forward}{restart}{quicksave}{quickload}{saves}{settings}</header>\n{passage}';
|
|
@@ -16,7 +16,7 @@ export function StoryInterface() {
|
|
|
16
16
|
try {
|
|
17
17
|
const tokens = tokenize(markup);
|
|
18
18
|
const ast = buildAST(tokens);
|
|
19
|
-
return <>{
|
|
19
|
+
return <>{renderInlineNodes(ast)}</>;
|
|
20
20
|
} catch (err) {
|
|
21
21
|
return (
|
|
22
22
|
<span class="error">
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback } from 'preact/hooks';
|
|
2
|
+
import { PassageDialog } from './PassageDialog';
|
|
3
|
+
import {
|
|
4
|
+
subscribeTriggerDialogs,
|
|
5
|
+
shiftDialogQueue,
|
|
6
|
+
dialogQueueLength,
|
|
7
|
+
} from '../triggers';
|
|
8
|
+
|
|
9
|
+
export function TriggerDialogHost() {
|
|
10
|
+
const [current, setCurrent] = useState<string | null>(null);
|
|
11
|
+
|
|
12
|
+
const advance = useCallback(() => {
|
|
13
|
+
const next = shiftDialogQueue();
|
|
14
|
+
setCurrent(next ?? null);
|
|
15
|
+
}, []);
|
|
16
|
+
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
return subscribeTriggerDialogs(() => {
|
|
19
|
+
setCurrent((prev) => {
|
|
20
|
+
if (prev !== null) return prev; // already showing one
|
|
21
|
+
return shiftDialogQueue() ?? null;
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
}, []);
|
|
25
|
+
|
|
26
|
+
const handleClose = useCallback(() => {
|
|
27
|
+
setCurrent(null);
|
|
28
|
+
// Show next queued dialog after a tick
|
|
29
|
+
if (dialogQueueLength() > 0) {
|
|
30
|
+
requestAnimationFrame(advance);
|
|
31
|
+
}
|
|
32
|
+
}, [advance]);
|
|
33
|
+
|
|
34
|
+
if (!current) return null;
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<PassageDialog
|
|
38
|
+
passageName={current}
|
|
39
|
+
onClose={handleClose}
|
|
40
|
+
/>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
@@ -1,25 +1,40 @@
|
|
|
1
|
+
import { h, render } from 'preact';
|
|
1
2
|
import { defineMacro } from '../../define-macro';
|
|
3
|
+
import {
|
|
4
|
+
renderNodes,
|
|
5
|
+
LocalsUpdateContext,
|
|
6
|
+
LocalsValuesContext,
|
|
7
|
+
} from '../../markup/render';
|
|
2
8
|
|
|
3
9
|
defineMacro({
|
|
4
10
|
name: 'button',
|
|
5
11
|
interpolate: true,
|
|
6
12
|
render({ rawArgs, children = [] }, ctx) {
|
|
13
|
+
const label = ctx.resolve?.(rawArgs.replace(/^["']|["']$/g, '')) ?? rawArgs;
|
|
14
|
+
|
|
7
15
|
const handleClick = () => {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
+
// Render children into a detached DOM node — all macro side effects
|
|
17
|
+
// ({set}, {if}, {unwatch}, etc.) fire through the normal Preact pipeline.
|
|
18
|
+
// Wrap with locals context so @local variables from for-loops are available.
|
|
19
|
+
const container = document.createElement('div');
|
|
20
|
+
const vnode = h(
|
|
21
|
+
LocalsUpdateContext.Provider,
|
|
22
|
+
{ value: { update: ctx.update, getValues: ctx.getValues } },
|
|
23
|
+
h(
|
|
24
|
+
LocalsValuesContext.Provider,
|
|
25
|
+
{ value: ctx.getValues() },
|
|
26
|
+
renderNodes(children),
|
|
27
|
+
),
|
|
28
|
+
);
|
|
29
|
+
render(vnode, container);
|
|
30
|
+
render(null, container);
|
|
16
31
|
};
|
|
17
32
|
|
|
18
33
|
ctx.useAction({
|
|
19
34
|
type: 'button',
|
|
20
35
|
key: rawArgs,
|
|
21
36
|
authorId: ctx.id,
|
|
22
|
-
label
|
|
37
|
+
label,
|
|
23
38
|
perform: handleClick,
|
|
24
39
|
});
|
|
25
40
|
|
|
@@ -29,7 +44,7 @@ defineMacro({
|
|
|
29
44
|
class={ctx.cls}
|
|
30
45
|
onClick={handleClick}
|
|
31
46
|
>
|
|
32
|
-
{
|
|
47
|
+
{label}
|
|
33
48
|
</button>
|
|
34
49
|
);
|
|
35
50
|
},
|
|
@@ -3,21 +3,15 @@ import { PassageDialog } from '../PassageDialog';
|
|
|
3
3
|
|
|
4
4
|
defineMacro({
|
|
5
5
|
name: 'dialog',
|
|
6
|
-
|
|
6
|
+
interpolate: true,
|
|
7
7
|
render({ rawArgs, children = [] }, ctx) {
|
|
8
8
|
const [open, setOpen] = ctx.hooks.useState(false);
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
passageName = rawArgs.replace(/^["']|["']$/g, '').trim();
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
const label = children.length
|
|
19
|
-
? ctx.renderInlineNodes(children)
|
|
20
|
-
: passageName;
|
|
10
|
+
const label = ctx.resolve?.(rawArgs.replace(/^["']|["']$/g, '')) ?? rawArgs;
|
|
11
|
+
const passageName = ctx
|
|
12
|
+
.collectText(children)
|
|
13
|
+
.trim()
|
|
14
|
+
.replace(/^["']|["']$/g, '');
|
|
21
15
|
|
|
22
16
|
return (
|
|
23
17
|
<>
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import { h, render } from 'preact';
|
|
1
2
|
import { useStoryStore } from '../../store';
|
|
2
|
-
import
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
import {
|
|
4
|
+
renderNodes,
|
|
5
|
+
LocalsUpdateContext,
|
|
6
|
+
LocalsValuesContext,
|
|
7
|
+
} from '../../markup/render';
|
|
6
8
|
import { defineMacro } from '../../define-macro';
|
|
7
9
|
|
|
8
10
|
function parseArgs(rawArgs: string): {
|
|
@@ -11,10 +13,10 @@ function parseArgs(rawArgs: string): {
|
|
|
11
13
|
} {
|
|
12
14
|
// {link "text" "Passage"} or {link "text"}
|
|
13
15
|
const parts: string[] = [];
|
|
14
|
-
const re = /
|
|
16
|
+
const re = /(["'])(.*?)\1/g;
|
|
15
17
|
let m;
|
|
16
18
|
while ((m = re.exec(rawArgs)) !== null) {
|
|
17
|
-
parts.push(m[
|
|
19
|
+
parts.push(m[2]!);
|
|
18
20
|
}
|
|
19
21
|
if (parts.length >= 2) {
|
|
20
22
|
return { display: parts[0]!, passage: parts[1]! };
|
|
@@ -26,37 +28,23 @@ function parseArgs(rawArgs: string): {
|
|
|
26
28
|
return { display: rawArgs.trim(), passage: null };
|
|
27
29
|
}
|
|
28
30
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
children: ASTNode[],
|
|
34
|
-
mergedLocals: Record<string, unknown>,
|
|
35
|
-
scopeUpdate: (key: string, value: unknown) => void,
|
|
31
|
+
function renderChildrenDetached(
|
|
32
|
+
children: import('../../markup/ast').ASTNode[],
|
|
33
|
+
getValues: () => Record<string, unknown>,
|
|
34
|
+
update: (key: string, value: unknown) => void,
|
|
36
35
|
) {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
const code = collectText(node.children);
|
|
50
|
-
try {
|
|
51
|
-
executeMutation(code, mergedLocals, scopeUpdate);
|
|
52
|
-
} catch (err) {
|
|
53
|
-
console.error(
|
|
54
|
-
`spindle: Error in {link} child {do}${currentSourceLocation()}:`,
|
|
55
|
-
err,
|
|
56
|
-
);
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
}
|
|
36
|
+
const container = document.createElement('div');
|
|
37
|
+
const vnode = h(
|
|
38
|
+
LocalsUpdateContext.Provider,
|
|
39
|
+
{ value: { update, getValues } },
|
|
40
|
+
h(
|
|
41
|
+
LocalsValuesContext.Provider,
|
|
42
|
+
{ value: getValues() },
|
|
43
|
+
renderNodes(children),
|
|
44
|
+
),
|
|
45
|
+
);
|
|
46
|
+
render(vnode, container);
|
|
47
|
+
render(null, container);
|
|
60
48
|
}
|
|
61
49
|
|
|
62
50
|
defineMacro({
|
|
@@ -67,7 +55,7 @@ defineMacro({
|
|
|
67
55
|
|
|
68
56
|
const handleClick = (e: Event) => {
|
|
69
57
|
e.preventDefault();
|
|
70
|
-
|
|
58
|
+
renderChildrenDetached(children, ctx.getValues, ctx.update);
|
|
71
59
|
if (passage) {
|
|
72
60
|
useStoryStore.getState().navigate(passage);
|
|
73
61
|
}
|
|
@@ -80,7 +68,7 @@ defineMacro({
|
|
|
80
68
|
label: display,
|
|
81
69
|
target: passage ?? undefined,
|
|
82
70
|
perform: () => {
|
|
83
|
-
|
|
71
|
+
renderChildrenDetached(children, ctx.getValues, ctx.update);
|
|
84
72
|
if (passage) {
|
|
85
73
|
useStoryStore.getState().navigate(passage);
|
|
86
74
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useStoryStore } from '../../store';
|
|
2
|
-
import { Passage } from '../Passage';
|
|
2
|
+
import { Passage, renderPassageContent } from '../Passage';
|
|
3
3
|
import { defineMacro } from '../../define-macro';
|
|
4
4
|
|
|
5
5
|
defineMacro({
|
|
@@ -18,11 +18,21 @@ defineMacro({
|
|
|
18
18
|
);
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
const readyPassage = storyData?.passages.get('PassageReady');
|
|
22
|
+
|
|
21
23
|
return (
|
|
22
24
|
<div
|
|
23
25
|
id={ctx.id ?? 'story'}
|
|
24
26
|
class={ctx.className ?? 'story'}
|
|
25
27
|
>
|
|
28
|
+
{readyPassage && (
|
|
29
|
+
<div
|
|
30
|
+
key={`ready-${currentPassage}`}
|
|
31
|
+
hidden
|
|
32
|
+
>
|
|
33
|
+
{renderPassageContent(readyPassage)}
|
|
34
|
+
</div>
|
|
35
|
+
)}
|
|
26
36
|
<Passage
|
|
27
37
|
passage={passage}
|
|
28
38
|
key={currentPassage}
|
|
@@ -32,22 +32,24 @@ defineMacro({
|
|
|
32
32
|
return () => clearTimeout(timer);
|
|
33
33
|
}, [visibleIndex, sections]);
|
|
34
34
|
|
|
35
|
-
if (visibleIndex < 0) return null;
|
|
35
|
+
if (visibleIndex < 0) return ctx.wrap(null);
|
|
36
36
|
|
|
37
37
|
const section = sections[visibleIndex]!;
|
|
38
38
|
const content = ctx.renderNodes(section.nodes);
|
|
39
39
|
const sectionClass = ctx.resolve!(section.className);
|
|
40
40
|
const sectionId = ctx.resolve!(section.id);
|
|
41
41
|
|
|
42
|
-
|
|
43
|
-
|
|
42
|
+
const inner =
|
|
43
|
+
sectionClass || sectionId ? (
|
|
44
44
|
<span
|
|
45
45
|
id={sectionId}
|
|
46
46
|
class={sectionClass}
|
|
47
47
|
>
|
|
48
48
|
{content}
|
|
49
49
|
</span>
|
|
50
|
+
) : (
|
|
51
|
+
content
|
|
50
52
|
);
|
|
51
|
-
return
|
|
53
|
+
return ctx.wrap(inner);
|
|
52
54
|
},
|
|
53
55
|
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { defineMacro } from '../../define-macro';
|
|
2
|
+
import { addTrigger, removeTrigger } from '../../triggers';
|
|
3
|
+
import type { WatchOptions } from '../../triggers';
|
|
4
|
+
|
|
5
|
+
defineMacro({
|
|
6
|
+
name: 'watch',
|
|
7
|
+
render(props, ctx) {
|
|
8
|
+
const { hooks } = ctx;
|
|
9
|
+
|
|
10
|
+
// Parse: condition string + keyword options
|
|
11
|
+
const raw = props.rawArgs.trim();
|
|
12
|
+
// First quoted string is the condition
|
|
13
|
+
const condMatch = raw.match(/^(['"])(.*?)\1\s*/);
|
|
14
|
+
if (!condMatch) return null;
|
|
15
|
+
|
|
16
|
+
const condition = condMatch[2]!;
|
|
17
|
+
const rest = raw.slice(condMatch[0].length);
|
|
18
|
+
|
|
19
|
+
const options: WatchOptions = {};
|
|
20
|
+
// Parse keyword args: goto "X", dialog "X", run "X", once, name "X", priority N
|
|
21
|
+
const kwRe = /(\w+)\s+(?:"([^"]*?)"|'([^']*?)'|(\d+))|(\w+)/g;
|
|
22
|
+
let m: RegExpExecArray | null;
|
|
23
|
+
while ((m = kwRe.exec(rest)) !== null) {
|
|
24
|
+
const key = m[1] ?? m[5];
|
|
25
|
+
const val = m[2] ?? m[3] ?? m[4];
|
|
26
|
+
if (!key) continue;
|
|
27
|
+
switch (key) {
|
|
28
|
+
case 'goto':
|
|
29
|
+
options.goto = val;
|
|
30
|
+
break;
|
|
31
|
+
case 'dialog':
|
|
32
|
+
options.dialog = val;
|
|
33
|
+
break;
|
|
34
|
+
case 'run':
|
|
35
|
+
options.run = val;
|
|
36
|
+
break;
|
|
37
|
+
case 'name':
|
|
38
|
+
options.name = val;
|
|
39
|
+
break;
|
|
40
|
+
case 'priority':
|
|
41
|
+
options.priority = val ? Number(val) : 0;
|
|
42
|
+
break;
|
|
43
|
+
case 'once':
|
|
44
|
+
options.once = true;
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Register during render (like {set}) — triggers survive navigation
|
|
50
|
+
// and are cleaned up via resetTriggers() on restart or {unwatch}.
|
|
51
|
+
const registered = hooks.useRef(false);
|
|
52
|
+
if (!registered.current) {
|
|
53
|
+
registered.current = true;
|
|
54
|
+
addTrigger(condition, options);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return null;
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
defineMacro({
|
|
62
|
+
name: 'unwatch',
|
|
63
|
+
render(props, ctx) {
|
|
64
|
+
const raw = props.rawArgs.trim();
|
|
65
|
+
const name = raw.replace(/^['"]|['"]$/g, '');
|
|
66
|
+
|
|
67
|
+
const ran = ctx.hooks.useRef(false);
|
|
68
|
+
if (!ran.current) {
|
|
69
|
+
ran.current = true;
|
|
70
|
+
removeTrigger(name);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return null;
|
|
74
|
+
},
|
|
75
|
+
});
|
package/src/expression.ts
CHANGED
|
@@ -6,12 +6,12 @@ import { random, randomInt } from './prng';
|
|
|
6
6
|
interface ExpressionFns {
|
|
7
7
|
currentPassage: () => Passage | undefined;
|
|
8
8
|
previousPassage: () => Passage | undefined;
|
|
9
|
-
visited: (name
|
|
10
|
-
hasVisited: (name
|
|
9
|
+
visited: (name?: string) => number;
|
|
10
|
+
hasVisited: (name?: string) => boolean;
|
|
11
11
|
hasVisitedAny: (...names: string[]) => boolean;
|
|
12
12
|
hasVisitedAll: (...names: string[]) => boolean;
|
|
13
|
-
rendered: (name
|
|
14
|
-
hasRendered: (name
|
|
13
|
+
rendered: (name?: string) => number;
|
|
14
|
+
hasRendered: (name?: string) => boolean;
|
|
15
15
|
hasRenderedAny: (...names: string[]) => boolean;
|
|
16
16
|
hasRenderedAll: (...names: string[]) => boolean;
|
|
17
17
|
random: () => number;
|
|
@@ -191,15 +191,17 @@ export function buildExpressionFns() {
|
|
|
191
191
|
return cachedFns;
|
|
192
192
|
}
|
|
193
193
|
|
|
194
|
-
const visited = (name
|
|
195
|
-
|
|
194
|
+
const visited = (name?: string): number =>
|
|
195
|
+
visitCounts[name ?? useStoryStore.getState().currentPassage] ?? 0;
|
|
196
|
+
const hasVisited = (name?: string): boolean => visited(name) > 0;
|
|
196
197
|
const hasVisitedAny = (...names: string[]): boolean =>
|
|
197
198
|
names.some((n) => visited(n) > 0);
|
|
198
199
|
const hasVisitedAll = (...names: string[]): boolean =>
|
|
199
200
|
names.every((n) => visited(n) > 0);
|
|
200
201
|
|
|
201
|
-
const rendered = (name
|
|
202
|
-
|
|
202
|
+
const rendered = (name?: string): number =>
|
|
203
|
+
renderCounts[name ?? useStoryStore.getState().currentPassage] ?? 0;
|
|
204
|
+
const hasRendered = (name?: string): boolean => rendered(name) > 0;
|
|
203
205
|
const hasRenderedAny = (...names: string[]): boolean =>
|
|
204
206
|
names.some((n) => rendered(n) > 0);
|
|
205
207
|
const hasRenderedAll = (...names: string[]): boolean =>
|
package/src/index.tsx
CHANGED
|
@@ -5,6 +5,8 @@ import { useStoryStore } from './store';
|
|
|
5
5
|
import { installStoryAPI } from './story-api';
|
|
6
6
|
import { resetIdCounters } from './action-registry';
|
|
7
7
|
import { executeStoryInit } from './story-init';
|
|
8
|
+
import { checkTriggers, reinitTriggerState } from './triggers';
|
|
9
|
+
import { loadSession } from './saves/save-manager';
|
|
8
10
|
import {
|
|
9
11
|
parseStoryVariables,
|
|
10
12
|
validatePassages,
|
|
@@ -89,6 +91,12 @@ function boot() {
|
|
|
89
91
|
// Execute StoryInit passage if it exists
|
|
90
92
|
executeStoryInit();
|
|
91
93
|
|
|
94
|
+
// Restore session state if the page was refreshed
|
|
95
|
+
const sessionPayload = loadSession(storyData.ifid);
|
|
96
|
+
if (sessionPayload) {
|
|
97
|
+
useStoryStore.getState().loadFromPayload(sessionPayload);
|
|
98
|
+
}
|
|
99
|
+
|
|
92
100
|
// Register widgets from passages tagged "widget"
|
|
93
101
|
for (const [, passage] of storyData.passages) {
|
|
94
102
|
if (passage.tags.includes('widget')) {
|
|
@@ -119,6 +127,21 @@ function boot() {
|
|
|
119
127
|
}
|
|
120
128
|
});
|
|
121
129
|
|
|
130
|
+
// Wire up trigger system: check triggers on variable mutations, reinit on history nav/load
|
|
131
|
+
let prevVars = useStoryStore.getState().variables;
|
|
132
|
+
let prevHistoryIndex = useStoryStore.getState().historyIndex;
|
|
133
|
+
useStoryStore.subscribe((state) => {
|
|
134
|
+
if (state.variables !== prevVars) {
|
|
135
|
+
if (state.historyIndex === prevHistoryIndex) {
|
|
136
|
+
checkTriggers();
|
|
137
|
+
} else {
|
|
138
|
+
reinitTriggerState();
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
prevVars = state.variables;
|
|
142
|
+
prevHistoryIndex = state.historyIndex;
|
|
143
|
+
});
|
|
144
|
+
|
|
122
145
|
// Warn if StoryInterface passage exists but doesn't contain {passage}
|
|
123
146
|
const storyInterfacePassage = storyData.passages.get('StoryInterface');
|
|
124
147
|
if (
|
|
@@ -29,4 +29,6 @@ import '../components/macros/Checkbox';
|
|
|
29
29
|
import '../components/macros/Radiobutton';
|
|
30
30
|
import '../components/macros/Listbox';
|
|
31
31
|
import '../components/macros/Cycle';
|
|
32
|
+
import '../components/macros/Span';
|
|
32
33
|
import '../components/macros/PassageDisplay';
|
|
34
|
+
import '../components/macros/Watch';
|