@rohal12/spindle 0.1.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/README.md +66 -0
- package/dist/pkg/format.js +1 -0
- package/dist/pkg/index.js +12 -0
- package/dist/pkg/types/globals.d.ts +18 -0
- package/dist/pkg/types/index.d.ts +158 -0
- package/package.json +71 -0
- package/src/components/App.tsx +53 -0
- package/src/components/Passage.tsx +36 -0
- package/src/components/PassageLink.tsx +35 -0
- package/src/components/SaveLoadDialog.tsx +403 -0
- package/src/components/SettingsDialog.tsx +106 -0
- package/src/components/StoryInterface.tsx +31 -0
- package/src/components/macros/Back.tsx +23 -0
- package/src/components/macros/Button.tsx +49 -0
- package/src/components/macros/Checkbox.tsx +41 -0
- package/src/components/macros/Computed.tsx +100 -0
- package/src/components/macros/Cycle.tsx +39 -0
- package/src/components/macros/Do.tsx +46 -0
- package/src/components/macros/For.tsx +113 -0
- package/src/components/macros/Forward.tsx +25 -0
- package/src/components/macros/Goto.tsx +23 -0
- package/src/components/macros/If.tsx +63 -0
- package/src/components/macros/Include.tsx +52 -0
- package/src/components/macros/Listbox.tsx +42 -0
- package/src/components/macros/MacroLink.tsx +107 -0
- package/src/components/macros/Numberbox.tsx +43 -0
- package/src/components/macros/Print.tsx +48 -0
- package/src/components/macros/QuickLoad.tsx +33 -0
- package/src/components/macros/QuickSave.tsx +22 -0
- package/src/components/macros/Radiobutton.tsx +59 -0
- package/src/components/macros/Repeat.tsx +53 -0
- package/src/components/macros/Restart.tsx +27 -0
- package/src/components/macros/Saves.tsx +25 -0
- package/src/components/macros/Set.tsx +36 -0
- package/src/components/macros/SettingsButton.tsx +29 -0
- package/src/components/macros/Stop.tsx +12 -0
- package/src/components/macros/StoryTitle.tsx +20 -0
- package/src/components/macros/Switch.tsx +69 -0
- package/src/components/macros/Textarea.tsx +41 -0
- package/src/components/macros/Textbox.tsx +40 -0
- package/src/components/macros/Timed.tsx +63 -0
- package/src/components/macros/Type.tsx +83 -0
- package/src/components/macros/Unset.tsx +25 -0
- package/src/components/macros/VarDisplay.tsx +44 -0
- package/src/components/macros/Widget.tsx +18 -0
- package/src/components/macros/option-utils.ts +14 -0
- package/src/expression.ts +93 -0
- package/src/index.tsx +120 -0
- package/src/markup/ast.ts +284 -0
- package/src/markup/markdown.ts +21 -0
- package/src/markup/render.tsx +537 -0
- package/src/markup/tokenizer.ts +581 -0
- package/src/parser.ts +72 -0
- package/src/registry.ts +21 -0
- package/src/saves/idb.ts +165 -0
- package/src/saves/save-manager.ts +317 -0
- package/src/saves/types.ts +40 -0
- package/src/settings.ts +96 -0
- package/src/store.ts +317 -0
- package/src/story-api.ts +129 -0
- package/src/story-init.ts +67 -0
- package/src/story-variables.ts +166 -0
- package/src/styles.css +780 -0
- package/src/utils/parse-delay.ts +14 -0
- package/src/widgets/widget-registry.ts +15 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { useStoryStore } from '../../store';
|
|
2
|
+
import { useContext } from 'preact/hooks';
|
|
3
|
+
import { evaluate } from '../../expression';
|
|
4
|
+
import { LocalsContext } from '../../markup/render';
|
|
5
|
+
|
|
6
|
+
interface PrintProps {
|
|
7
|
+
rawArgs: string;
|
|
8
|
+
className?: string;
|
|
9
|
+
id?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function Print({ rawArgs, className, id }: PrintProps) {
|
|
13
|
+
const variables = useStoryStore((s) => s.variables);
|
|
14
|
+
const temporary = useStoryStore((s) => s.temporary);
|
|
15
|
+
const locals = useContext(LocalsContext);
|
|
16
|
+
|
|
17
|
+
// Merge locals into variables for expression evaluation
|
|
18
|
+
const mergedVars = { ...variables };
|
|
19
|
+
const mergedTemps = { ...temporary };
|
|
20
|
+
for (const [key, val] of Object.entries(locals)) {
|
|
21
|
+
if (key.startsWith('$')) mergedVars[key.slice(1)] = val;
|
|
22
|
+
else if (key.startsWith('_')) mergedTemps[key.slice(1)] = val;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
const result = evaluate(rawArgs, mergedVars, mergedTemps);
|
|
27
|
+
const display = result == null ? '' : String(result);
|
|
28
|
+
if (className || id)
|
|
29
|
+
return (
|
|
30
|
+
<span
|
|
31
|
+
id={id}
|
|
32
|
+
class={className}
|
|
33
|
+
>
|
|
34
|
+
{display}
|
|
35
|
+
</span>
|
|
36
|
+
);
|
|
37
|
+
return <>{display}</>;
|
|
38
|
+
} catch (err) {
|
|
39
|
+
return (
|
|
40
|
+
<span
|
|
41
|
+
class="error"
|
|
42
|
+
title={String(err)}
|
|
43
|
+
>
|
|
44
|
+
{`{print error: ${(err as Error).message}}`}
|
|
45
|
+
</span>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { useStoryStore } from '../../store';
|
|
2
|
+
|
|
3
|
+
interface QuickLoadProps {
|
|
4
|
+
className?: string;
|
|
5
|
+
id?: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function QuickLoad({ className, id }: QuickLoadProps) {
|
|
9
|
+
const load = useStoryStore((s) => s.load);
|
|
10
|
+
const hasSave = useStoryStore((s) => s.hasSave);
|
|
11
|
+
// Subscribe to saveVersion so we re-render when a save is created
|
|
12
|
+
useStoryStore((s) => s.saveVersion);
|
|
13
|
+
const cls = className ? `menubar-button ${className}` : 'menubar-button';
|
|
14
|
+
const disabled = !hasSave();
|
|
15
|
+
|
|
16
|
+
const handleClick = () => {
|
|
17
|
+
if (confirm('Load saved game? Current progress will be lost.')) {
|
|
18
|
+
load();
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<button
|
|
24
|
+
id={id}
|
|
25
|
+
class={cls}
|
|
26
|
+
title="Quick Load (F9)"
|
|
27
|
+
disabled={disabled}
|
|
28
|
+
onClick={handleClick}
|
|
29
|
+
>
|
|
30
|
+
QuickLoad
|
|
31
|
+
</button>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { useStoryStore } from '../../store';
|
|
2
|
+
|
|
3
|
+
interface QuickSaveProps {
|
|
4
|
+
className?: string;
|
|
5
|
+
id?: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function QuickSave({ className, id }: QuickSaveProps) {
|
|
9
|
+
const save = useStoryStore((s) => s.save);
|
|
10
|
+
const cls = className ? `menubar-button ${className}` : 'menubar-button';
|
|
11
|
+
|
|
12
|
+
return (
|
|
13
|
+
<button
|
|
14
|
+
id={id}
|
|
15
|
+
class={cls}
|
|
16
|
+
title="Quick Save (F6)"
|
|
17
|
+
onClick={() => save()}
|
|
18
|
+
>
|
|
19
|
+
QuickSave
|
|
20
|
+
</button>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { useStoryStore } from '../../store';
|
|
2
|
+
|
|
3
|
+
interface RadiobuttonProps {
|
|
4
|
+
rawArgs: string;
|
|
5
|
+
className?: string;
|
|
6
|
+
id?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function parseArgs(rawArgs: string): {
|
|
10
|
+
varName: string;
|
|
11
|
+
value: string;
|
|
12
|
+
label: string;
|
|
13
|
+
} {
|
|
14
|
+
// {radiobutton "$var" "value" "Label text"}
|
|
15
|
+
const match = rawArgs.match(
|
|
16
|
+
/^\s*(["']?\$\w+["']?)\s+["'](.+?)["']\s+["']?(.+?)["']?\s*$/,
|
|
17
|
+
);
|
|
18
|
+
if (!match) {
|
|
19
|
+
// Try simpler: $var value label
|
|
20
|
+
const parts = rawArgs.trim().split(/\s+/);
|
|
21
|
+
return {
|
|
22
|
+
varName: (parts[0] || '').replace(/["']/g, ''),
|
|
23
|
+
value: parts[1] || '',
|
|
24
|
+
label: parts.slice(2).join(' '),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
return {
|
|
28
|
+
varName: match[1].replace(/["']/g, ''),
|
|
29
|
+
value: match[2],
|
|
30
|
+
label: match[3],
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function Radiobutton({ rawArgs, className, id }: RadiobuttonProps) {
|
|
35
|
+
const { varName, value: radioValue, label } = parseArgs(rawArgs);
|
|
36
|
+
const name = varName.startsWith('$') ? varName.slice(1) : varName;
|
|
37
|
+
|
|
38
|
+
const currentValue = useStoryStore((s) => s.variables[name]);
|
|
39
|
+
const setVariable = useStoryStore((s) => s.setVariable);
|
|
40
|
+
|
|
41
|
+
const cls = className
|
|
42
|
+
? `macro-radiobutton ${className}`
|
|
43
|
+
: 'macro-radiobutton';
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<label
|
|
47
|
+
id={id}
|
|
48
|
+
class={cls}
|
|
49
|
+
>
|
|
50
|
+
<input
|
|
51
|
+
type="radio"
|
|
52
|
+
name={`radio-${name}`}
|
|
53
|
+
checked={currentValue === radioValue}
|
|
54
|
+
onChange={() => setVariable(name, radioValue)}
|
|
55
|
+
/>
|
|
56
|
+
{label ? ` ${label}` : null}
|
|
57
|
+
</label>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { createContext } from 'preact';
|
|
2
|
+
import { useState, useEffect, useCallback } from 'preact/hooks';
|
|
3
|
+
import { renderNodes } from '../../markup/render';
|
|
4
|
+
import { parseDelay } from '../../utils/parse-delay';
|
|
5
|
+
import type { ASTNode } from '../../markup/ast';
|
|
6
|
+
|
|
7
|
+
export const RepeatContext = createContext<{ stop: () => void }>({
|
|
8
|
+
stop: () => {},
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
interface RepeatProps {
|
|
12
|
+
rawArgs: string;
|
|
13
|
+
children: ASTNode[];
|
|
14
|
+
className?: string;
|
|
15
|
+
id?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function Repeat({ rawArgs, children, className, id }: RepeatProps) {
|
|
19
|
+
const delay = parseDelay(rawArgs);
|
|
20
|
+
const [count, setCount] = useState(0);
|
|
21
|
+
const [stopped, setStopped] = useState(false);
|
|
22
|
+
|
|
23
|
+
const stop = useCallback(() => setStopped(true), []);
|
|
24
|
+
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
if (stopped) return;
|
|
27
|
+
const interval = setInterval(() => {
|
|
28
|
+
setCount((c) => c + 1);
|
|
29
|
+
}, delay);
|
|
30
|
+
return () => clearInterval(interval);
|
|
31
|
+
}, [delay, stopped]);
|
|
32
|
+
|
|
33
|
+
if (count === 0 && !stopped) return null;
|
|
34
|
+
|
|
35
|
+
const cls = className ? `macro-repeat ${className}` : undefined;
|
|
36
|
+
|
|
37
|
+
const content = (
|
|
38
|
+
<RepeatContext.Provider value={{ stop }}>
|
|
39
|
+
{renderNodes(children)}
|
|
40
|
+
</RepeatContext.Provider>
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
if (cls || id)
|
|
44
|
+
return (
|
|
45
|
+
<span
|
|
46
|
+
id={id}
|
|
47
|
+
class={cls}
|
|
48
|
+
>
|
|
49
|
+
{content}
|
|
50
|
+
</span>
|
|
51
|
+
);
|
|
52
|
+
return content;
|
|
53
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { useStoryStore } from '../../store';
|
|
2
|
+
|
|
3
|
+
interface RestartProps {
|
|
4
|
+
className?: string;
|
|
5
|
+
id?: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function Restart({ className, id }: RestartProps) {
|
|
9
|
+
const restart = useStoryStore((s) => s.restart);
|
|
10
|
+
const cls = className ? `menubar-button ${className}` : 'menubar-button';
|
|
11
|
+
|
|
12
|
+
const handleClick = () => {
|
|
13
|
+
if (confirm('Restart the story? All progress will be lost.')) {
|
|
14
|
+
restart();
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<button
|
|
20
|
+
id={id}
|
|
21
|
+
class={cls}
|
|
22
|
+
onClick={handleClick}
|
|
23
|
+
>
|
|
24
|
+
↺ Restart
|
|
25
|
+
</button>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { useState } from 'preact/hooks';
|
|
2
|
+
import { SaveLoadDialog } from '../SaveLoadDialog';
|
|
3
|
+
|
|
4
|
+
interface SavesProps {
|
|
5
|
+
className?: string;
|
|
6
|
+
id?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function Saves({ className, id }: SavesProps) {
|
|
10
|
+
const [open, setOpen] = useState(false);
|
|
11
|
+
const cls = className ? `menubar-button ${className}` : 'menubar-button';
|
|
12
|
+
|
|
13
|
+
return (
|
|
14
|
+
<>
|
|
15
|
+
<button
|
|
16
|
+
id={id}
|
|
17
|
+
class={cls}
|
|
18
|
+
onClick={() => setOpen(true)}
|
|
19
|
+
>
|
|
20
|
+
Saves
|
|
21
|
+
</button>
|
|
22
|
+
{open && <SaveLoadDialog onClose={() => setOpen(false)} />}
|
|
23
|
+
</>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { useLayoutEffect } from 'preact/hooks';
|
|
2
|
+
import { useStoryStore } from '../../store';
|
|
3
|
+
import { execute } from '../../expression';
|
|
4
|
+
|
|
5
|
+
interface SetProps {
|
|
6
|
+
rawArgs: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function Set({ rawArgs }: SetProps) {
|
|
10
|
+
useLayoutEffect(() => {
|
|
11
|
+
const state = useStoryStore.getState();
|
|
12
|
+
const vars = structuredClone(state.variables);
|
|
13
|
+
const temps = structuredClone(state.temporary);
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
execute(rawArgs, vars, temps);
|
|
17
|
+
} catch (err) {
|
|
18
|
+
console.error(`spindle: Error in {set ${rawArgs}}:`, err);
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Diff and apply changes
|
|
23
|
+
for (const key of Object.keys(vars)) {
|
|
24
|
+
if (vars[key] !== state.variables[key]) {
|
|
25
|
+
state.setVariable(key, vars[key]);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
for (const key of Object.keys(temps)) {
|
|
29
|
+
if (temps[key] !== state.temporary[key]) {
|
|
30
|
+
state.setTemporary(key, temps[key]);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}, []);
|
|
34
|
+
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { useState } from 'preact/hooks';
|
|
2
|
+
import { settings } from '../../settings';
|
|
3
|
+
import { SettingsDialog } from '../SettingsDialog';
|
|
4
|
+
|
|
5
|
+
interface SettingsButtonProps {
|
|
6
|
+
className?: string;
|
|
7
|
+
id?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function SettingsButton({ className, id }: SettingsButtonProps) {
|
|
11
|
+
const [open, setOpen] = useState(false);
|
|
12
|
+
|
|
13
|
+
if (!settings.hasAny()) return null;
|
|
14
|
+
|
|
15
|
+
const cls = className ? `menubar-button ${className}` : 'menubar-button';
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<>
|
|
19
|
+
<button
|
|
20
|
+
id={id}
|
|
21
|
+
class={cls}
|
|
22
|
+
onClick={() => setOpen(true)}
|
|
23
|
+
>
|
|
24
|
+
⚙ Settings
|
|
25
|
+
</button>
|
|
26
|
+
{open && <SettingsDialog onClose={() => setOpen(false)} />}
|
|
27
|
+
</>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { useStoryStore } from '../../store';
|
|
2
|
+
|
|
3
|
+
interface StoryTitleProps {
|
|
4
|
+
className?: string;
|
|
5
|
+
id?: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function StoryTitle({ className, id }: StoryTitleProps) {
|
|
9
|
+
const name = useStoryStore((s) => s.storyData?.name || '');
|
|
10
|
+
const cls = className ? `story-title ${className}` : 'story-title';
|
|
11
|
+
|
|
12
|
+
return (
|
|
13
|
+
<span
|
|
14
|
+
id={id}
|
|
15
|
+
class={cls}
|
|
16
|
+
>
|
|
17
|
+
{name}
|
|
18
|
+
</span>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { useStoryStore } from '../../store';
|
|
2
|
+
import { useContext } from 'preact/hooks';
|
|
3
|
+
import { evaluate } from '../../expression';
|
|
4
|
+
import { LocalsContext, renderNodes } from '../../markup/render';
|
|
5
|
+
import type { Branch } from '../../markup/ast';
|
|
6
|
+
|
|
7
|
+
interface SwitchProps {
|
|
8
|
+
rawArgs: string;
|
|
9
|
+
branches: Branch[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function Switch({ rawArgs, branches }: SwitchProps) {
|
|
13
|
+
const variables = useStoryStore((s) => s.variables);
|
|
14
|
+
const temporary = useStoryStore((s) => s.temporary);
|
|
15
|
+
const locals = useContext(LocalsContext);
|
|
16
|
+
|
|
17
|
+
const mergedVars = { ...variables };
|
|
18
|
+
const mergedTemps = { ...temporary };
|
|
19
|
+
for (const [key, val] of Object.entries(locals)) {
|
|
20
|
+
if (key.startsWith('$')) mergedVars[key.slice(1)] = val;
|
|
21
|
+
else if (key.startsWith('_')) mergedTemps[key.slice(1)] = val;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let switchValue: unknown;
|
|
25
|
+
try {
|
|
26
|
+
switchValue = evaluate(rawArgs, mergedVars, mergedTemps);
|
|
27
|
+
} catch (err) {
|
|
28
|
+
return (
|
|
29
|
+
<span
|
|
30
|
+
class="error"
|
|
31
|
+
title={String(err)}
|
|
32
|
+
>
|
|
33
|
+
{`{switch error: ${(err as Error).message}}`}
|
|
34
|
+
</span>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Find matching {case} branch or {default}
|
|
39
|
+
let defaultBranch: Branch | null = null;
|
|
40
|
+
for (const branch of branches) {
|
|
41
|
+
// {default} has empty rawArgs
|
|
42
|
+
if (branch.rawArgs === '') {
|
|
43
|
+
defaultBranch = branch;
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
const caseValue = evaluate(branch.rawArgs, mergedVars, mergedTemps);
|
|
49
|
+
if (switchValue === caseValue) {
|
|
50
|
+
return <>{renderNodes(branch.children)}</>;
|
|
51
|
+
}
|
|
52
|
+
} catch (err) {
|
|
53
|
+
return (
|
|
54
|
+
<span
|
|
55
|
+
class="error"
|
|
56
|
+
title={String(err)}
|
|
57
|
+
>
|
|
58
|
+
{`{case error: ${(err as Error).message}}`}
|
|
59
|
+
</span>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (defaultBranch) {
|
|
65
|
+
return <>{renderNodes(defaultBranch.children)}</>;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { useStoryStore } from '../../store';
|
|
2
|
+
|
|
3
|
+
interface TextareaProps {
|
|
4
|
+
rawArgs: string;
|
|
5
|
+
className?: string;
|
|
6
|
+
id?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function parseArgs(rawArgs: string): { varName: string; placeholder: string } {
|
|
10
|
+
const match = rawArgs.match(
|
|
11
|
+
/^\s*(["']?\$\w+["']?)\s*(?:["'](.*)["'])?\s*$/,
|
|
12
|
+
);
|
|
13
|
+
if (!match) {
|
|
14
|
+
return { varName: rawArgs.trim(), placeholder: '' };
|
|
15
|
+
}
|
|
16
|
+
const varName = match[1].replace(/["']/g, '');
|
|
17
|
+
const placeholder = match[2] || '';
|
|
18
|
+
return { varName, placeholder };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function Textarea({ rawArgs, className, id }: TextareaProps) {
|
|
22
|
+
const { varName, placeholder } = parseArgs(rawArgs);
|
|
23
|
+
const name = varName.startsWith('$') ? varName.slice(1) : varName;
|
|
24
|
+
|
|
25
|
+
const value = useStoryStore((s) => s.variables[name]);
|
|
26
|
+
const setVariable = useStoryStore((s) => s.setVariable);
|
|
27
|
+
|
|
28
|
+
const cls = className ? `macro-textarea ${className}` : 'macro-textarea';
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<textarea
|
|
32
|
+
id={id}
|
|
33
|
+
class={cls}
|
|
34
|
+
value={value == null ? '' : String(value)}
|
|
35
|
+
placeholder={placeholder}
|
|
36
|
+
onInput={(e) =>
|
|
37
|
+
setVariable(name, (e.target as HTMLTextAreaElement).value)
|
|
38
|
+
}
|
|
39
|
+
/>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { useStoryStore } from '../../store';
|
|
2
|
+
|
|
3
|
+
interface TextboxProps {
|
|
4
|
+
rawArgs: string;
|
|
5
|
+
className?: string;
|
|
6
|
+
id?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function parseArgs(rawArgs: string): { varName: string; placeholder: string } {
|
|
10
|
+
const match = rawArgs.match(
|
|
11
|
+
/^\s*(["']?\$\w+["']?)\s*(?:["'](.*)["'])?\s*$/,
|
|
12
|
+
);
|
|
13
|
+
if (!match) {
|
|
14
|
+
return { varName: rawArgs.trim(), placeholder: '' };
|
|
15
|
+
}
|
|
16
|
+
const varName = match[1].replace(/["']/g, '');
|
|
17
|
+
const placeholder = match[2] || '';
|
|
18
|
+
return { varName, placeholder };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function Textbox({ rawArgs, className, id }: TextboxProps) {
|
|
22
|
+
const { varName, placeholder } = parseArgs(rawArgs);
|
|
23
|
+
const name = varName.startsWith('$') ? varName.slice(1) : varName;
|
|
24
|
+
|
|
25
|
+
const value = useStoryStore((s) => s.variables[name]);
|
|
26
|
+
const setVariable = useStoryStore((s) => s.setVariable);
|
|
27
|
+
|
|
28
|
+
const cls = className ? `macro-textbox ${className}` : 'macro-textbox';
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<input
|
|
32
|
+
type="text"
|
|
33
|
+
id={id}
|
|
34
|
+
class={cls}
|
|
35
|
+
value={value == null ? '' : String(value)}
|
|
36
|
+
placeholder={placeholder}
|
|
37
|
+
onInput={(e) => setVariable(name, (e.target as HTMLInputElement).value)}
|
|
38
|
+
/>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { useState, useEffect } from 'preact/hooks';
|
|
2
|
+
import { renderNodes } from '../../markup/render';
|
|
3
|
+
import { parseDelay } from '../../utils/parse-delay';
|
|
4
|
+
import type { ASTNode, Branch } from '../../markup/ast';
|
|
5
|
+
|
|
6
|
+
interface TimedProps {
|
|
7
|
+
rawArgs: string;
|
|
8
|
+
children: ASTNode[];
|
|
9
|
+
branches: Branch[];
|
|
10
|
+
className?: string;
|
|
11
|
+
id?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function Timed({
|
|
15
|
+
rawArgs,
|
|
16
|
+
children,
|
|
17
|
+
branches,
|
|
18
|
+
className,
|
|
19
|
+
id,
|
|
20
|
+
}: TimedProps) {
|
|
21
|
+
// Section 0 = initial children, sections 1..N = {next} branches
|
|
22
|
+
// Each section has its own delay
|
|
23
|
+
const sections: { delay: number; nodes: ASTNode[] }[] = [];
|
|
24
|
+
|
|
25
|
+
// Initial content with the timed delay
|
|
26
|
+
sections.push({ delay: parseDelay(rawArgs), nodes: children });
|
|
27
|
+
|
|
28
|
+
// {next} branches
|
|
29
|
+
for (const branch of branches) {
|
|
30
|
+
const delay = branch.rawArgs ? parseDelay(branch.rawArgs) : 0;
|
|
31
|
+
sections.push({ delay, nodes: branch.children });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const [visibleIndex, setVisibleIndex] = useState(-1);
|
|
35
|
+
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
if (visibleIndex >= sections.length - 1) return;
|
|
38
|
+
|
|
39
|
+
const nextIndex = visibleIndex + 1;
|
|
40
|
+
const delay = sections[nextIndex].delay;
|
|
41
|
+
|
|
42
|
+
const timer = setTimeout(() => {
|
|
43
|
+
setVisibleIndex(nextIndex);
|
|
44
|
+
}, delay);
|
|
45
|
+
|
|
46
|
+
return () => clearTimeout(timer);
|
|
47
|
+
}, [visibleIndex, sections.length]);
|
|
48
|
+
|
|
49
|
+
if (visibleIndex < 0) return null;
|
|
50
|
+
|
|
51
|
+
const content = renderNodes(sections[visibleIndex].nodes);
|
|
52
|
+
|
|
53
|
+
if (className || id)
|
|
54
|
+
return (
|
|
55
|
+
<span
|
|
56
|
+
id={id}
|
|
57
|
+
class={className}
|
|
58
|
+
>
|
|
59
|
+
{content}
|
|
60
|
+
</span>
|
|
61
|
+
);
|
|
62
|
+
return <>{content}</>;
|
|
63
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { useState, useEffect, useRef } from 'preact/hooks';
|
|
2
|
+
import { renderInlineNodes } from '../../markup/render';
|
|
3
|
+
import { parseDelay } from '../../utils/parse-delay';
|
|
4
|
+
import type { ASTNode } from '../../markup/ast';
|
|
5
|
+
|
|
6
|
+
interface TypeProps {
|
|
7
|
+
rawArgs: string;
|
|
8
|
+
children: ASTNode[];
|
|
9
|
+
className?: string;
|
|
10
|
+
id?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function Type({ rawArgs, children, className, id }: TypeProps) {
|
|
14
|
+
const speed = parseDelay(rawArgs);
|
|
15
|
+
const containerRef = useRef<HTMLSpanElement>(null);
|
|
16
|
+
const [totalChars, setTotalChars] = useState(0);
|
|
17
|
+
const [visibleChars, setVisibleChars] = useState(0);
|
|
18
|
+
|
|
19
|
+
// After first render, measure total text length
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
if (containerRef.current) {
|
|
22
|
+
const text = containerRef.current.textContent || '';
|
|
23
|
+
setTotalChars(text.length);
|
|
24
|
+
}
|
|
25
|
+
}, []);
|
|
26
|
+
|
|
27
|
+
// Typewriter interval
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
if (totalChars === 0) return;
|
|
30
|
+
if (visibleChars >= totalChars) return;
|
|
31
|
+
|
|
32
|
+
const timer = setInterval(() => {
|
|
33
|
+
setVisibleChars((c) => {
|
|
34
|
+
if (c >= totalChars) {
|
|
35
|
+
clearInterval(timer);
|
|
36
|
+
return c;
|
|
37
|
+
}
|
|
38
|
+
return c + 1;
|
|
39
|
+
});
|
|
40
|
+
}, speed);
|
|
41
|
+
|
|
42
|
+
return () => clearInterval(timer);
|
|
43
|
+
}, [totalChars, speed]);
|
|
44
|
+
|
|
45
|
+
const done = visibleChars >= totalChars && totalChars > 0;
|
|
46
|
+
|
|
47
|
+
const cls = [
|
|
48
|
+
'macro-type',
|
|
49
|
+
done ? 'macro-type-done' : '',
|
|
50
|
+
className || '',
|
|
51
|
+
]
|
|
52
|
+
.filter(Boolean)
|
|
53
|
+
.join(' ');
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<span
|
|
57
|
+
id={id}
|
|
58
|
+
class={cls}
|
|
59
|
+
ref={containerRef}
|
|
60
|
+
style={{
|
|
61
|
+
clipPath:
|
|
62
|
+
totalChars > 0 && !done
|
|
63
|
+
? undefined
|
|
64
|
+
: undefined,
|
|
65
|
+
}}
|
|
66
|
+
>
|
|
67
|
+
<span
|
|
68
|
+
class="macro-type-inner"
|
|
69
|
+
style={{
|
|
70
|
+
display: 'inline',
|
|
71
|
+
visibility: totalChars === 0 ? 'hidden' : 'visible',
|
|
72
|
+
clipPath:
|
|
73
|
+
totalChars > 0 && !done
|
|
74
|
+
? `inset(0 ${((totalChars - visibleChars) / totalChars) * 100}% 0 0)`
|
|
75
|
+
: undefined,
|
|
76
|
+
}}
|
|
77
|
+
>
|
|
78
|
+
{renderInlineNodes(children)}
|
|
79
|
+
</span>
|
|
80
|
+
{!done && totalChars > 0 && <span class="macro-type-cursor" />}
|
|
81
|
+
</span>
|
|
82
|
+
);
|
|
83
|
+
}
|