@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.
Files changed (65) hide show
  1. package/README.md +66 -0
  2. package/dist/pkg/format.js +1 -0
  3. package/dist/pkg/index.js +12 -0
  4. package/dist/pkg/types/globals.d.ts +18 -0
  5. package/dist/pkg/types/index.d.ts +158 -0
  6. package/package.json +71 -0
  7. package/src/components/App.tsx +53 -0
  8. package/src/components/Passage.tsx +36 -0
  9. package/src/components/PassageLink.tsx +35 -0
  10. package/src/components/SaveLoadDialog.tsx +403 -0
  11. package/src/components/SettingsDialog.tsx +106 -0
  12. package/src/components/StoryInterface.tsx +31 -0
  13. package/src/components/macros/Back.tsx +23 -0
  14. package/src/components/macros/Button.tsx +49 -0
  15. package/src/components/macros/Checkbox.tsx +41 -0
  16. package/src/components/macros/Computed.tsx +100 -0
  17. package/src/components/macros/Cycle.tsx +39 -0
  18. package/src/components/macros/Do.tsx +46 -0
  19. package/src/components/macros/For.tsx +113 -0
  20. package/src/components/macros/Forward.tsx +25 -0
  21. package/src/components/macros/Goto.tsx +23 -0
  22. package/src/components/macros/If.tsx +63 -0
  23. package/src/components/macros/Include.tsx +52 -0
  24. package/src/components/macros/Listbox.tsx +42 -0
  25. package/src/components/macros/MacroLink.tsx +107 -0
  26. package/src/components/macros/Numberbox.tsx +43 -0
  27. package/src/components/macros/Print.tsx +48 -0
  28. package/src/components/macros/QuickLoad.tsx +33 -0
  29. package/src/components/macros/QuickSave.tsx +22 -0
  30. package/src/components/macros/Radiobutton.tsx +59 -0
  31. package/src/components/macros/Repeat.tsx +53 -0
  32. package/src/components/macros/Restart.tsx +27 -0
  33. package/src/components/macros/Saves.tsx +25 -0
  34. package/src/components/macros/Set.tsx +36 -0
  35. package/src/components/macros/SettingsButton.tsx +29 -0
  36. package/src/components/macros/Stop.tsx +12 -0
  37. package/src/components/macros/StoryTitle.tsx +20 -0
  38. package/src/components/macros/Switch.tsx +69 -0
  39. package/src/components/macros/Textarea.tsx +41 -0
  40. package/src/components/macros/Textbox.tsx +40 -0
  41. package/src/components/macros/Timed.tsx +63 -0
  42. package/src/components/macros/Type.tsx +83 -0
  43. package/src/components/macros/Unset.tsx +25 -0
  44. package/src/components/macros/VarDisplay.tsx +44 -0
  45. package/src/components/macros/Widget.tsx +18 -0
  46. package/src/components/macros/option-utils.ts +14 -0
  47. package/src/expression.ts +93 -0
  48. package/src/index.tsx +120 -0
  49. package/src/markup/ast.ts +284 -0
  50. package/src/markup/markdown.ts +21 -0
  51. package/src/markup/render.tsx +537 -0
  52. package/src/markup/tokenizer.ts +581 -0
  53. package/src/parser.ts +72 -0
  54. package/src/registry.ts +21 -0
  55. package/src/saves/idb.ts +165 -0
  56. package/src/saves/save-manager.ts +317 -0
  57. package/src/saves/types.ts +40 -0
  58. package/src/settings.ts +96 -0
  59. package/src/store.ts +317 -0
  60. package/src/story-api.ts +129 -0
  61. package/src/story-init.ts +67 -0
  62. package/src/story-variables.ts +166 -0
  63. package/src/styles.css +780 -0
  64. package/src/utils/parse-delay.ts +14 -0
  65. 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,12 @@
1
+ import { useLayoutEffect, useContext } from 'preact/hooks';
2
+ import { RepeatContext } from './Repeat';
3
+
4
+ export function Stop() {
5
+ const { stop } = useContext(RepeatContext);
6
+
7
+ useLayoutEffect(() => {
8
+ stop();
9
+ }, [stop]);
10
+
11
+ return null;
12
+ }
@@ -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
+ }