@magicborn/dialogue-forge 0.1.0 → 0.1.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.
Files changed (51) hide show
  1. package/demo/app/page.tsx +26 -12
  2. package/demo/public/logo.svg +14 -1
  3. package/dist/components/CharacterSelector.d.ts +15 -0
  4. package/dist/components/CharacterSelector.js +125 -0
  5. package/dist/components/ConditionalNodeV2.d.ts +2 -0
  6. package/dist/components/ConditionalNodeV2.js +50 -29
  7. package/dist/components/DialogueEditorV2.d.ts +2 -0
  8. package/dist/components/DialogueEditorV2.js +43 -6
  9. package/dist/components/GuidePanel.js +99 -16
  10. package/dist/components/NPCNodeV2.d.ts +2 -0
  11. package/dist/components/NPCNodeV2.js +31 -20
  12. package/dist/components/NodeEditor.d.ts +3 -1
  13. package/dist/components/NodeEditor.js +42 -13
  14. package/dist/components/PlayerNodeV2.d.ts +2 -0
  15. package/dist/components/PlayerNodeV2.js +58 -39
  16. package/dist/components/ReactFlowPOC.js +3 -3
  17. package/dist/esm/components/CharacterSelector.d.ts +15 -0
  18. package/dist/esm/components/CharacterSelector.js +89 -0
  19. package/dist/esm/components/ConditionalNodeV2.d.ts +2 -0
  20. package/dist/esm/components/ConditionalNodeV2.js +51 -30
  21. package/dist/esm/components/DialogueEditorV2.d.ts +2 -0
  22. package/dist/esm/components/DialogueEditorV2.js +43 -6
  23. package/dist/esm/components/GuidePanel.js +99 -16
  24. package/dist/esm/components/NPCNodeV2.d.ts +2 -0
  25. package/dist/esm/components/NPCNodeV2.js +32 -21
  26. package/dist/esm/components/NodeEditor.d.ts +3 -1
  27. package/dist/esm/components/NodeEditor.js +42 -13
  28. package/dist/esm/components/PlayerNodeV2.d.ts +2 -0
  29. package/dist/esm/components/PlayerNodeV2.js +59 -40
  30. package/dist/esm/components/ReactFlowPOC.js +3 -3
  31. package/dist/esm/examples/example-characters.d.ts +19 -0
  32. package/dist/esm/examples/example-characters.js +67 -0
  33. package/dist/esm/examples/index.d.ts +1 -0
  34. package/dist/esm/examples/index.js +2 -0
  35. package/dist/esm/index.d.ts +3 -0
  36. package/dist/esm/index.js +3 -0
  37. package/dist/esm/types/characters.d.ts +15 -0
  38. package/dist/esm/types/characters.js +6 -0
  39. package/dist/esm/types/game-state.d.ts +2 -0
  40. package/dist/esm/types/index.d.ts +2 -0
  41. package/dist/examples/example-characters.d.ts +19 -0
  42. package/dist/examples/example-characters.js +73 -0
  43. package/dist/examples/index.d.ts +1 -0
  44. package/dist/examples/index.js +7 -1
  45. package/dist/index.d.ts +3 -0
  46. package/dist/index.js +9 -1
  47. package/dist/types/characters.d.ts +15 -0
  48. package/dist/types/characters.js +7 -0
  49. package/dist/types/game-state.d.ts +2 -0
  50. package/dist/types/index.d.ts +2 -0
  51. package/package.json +1 -1
@@ -0,0 +1,89 @@
1
+ /**
2
+ * CharacterSelector - Combobox with search for selecting characters
3
+ */
4
+ import React, { useState, useRef, useEffect } from 'react';
5
+ import { Search, X, User } from 'lucide-react';
6
+ export function CharacterSelector({ characters = {}, selectedCharacterId, onSelect, placeholder = 'Select character...', className = '', compact = false, }) {
7
+ const [isOpen, setIsOpen] = useState(false);
8
+ const [searchQuery, setSearchQuery] = useState('');
9
+ const containerRef = useRef(null);
10
+ const inputRef = useRef(null);
11
+ const selectedCharacter = selectedCharacterId ? characters[selectedCharacterId] : undefined;
12
+ // Filter characters based on search query
13
+ const filteredCharacters = Object.entries(characters).filter(([id, character]) => {
14
+ if (!searchQuery.trim())
15
+ return true;
16
+ const query = searchQuery.toLowerCase();
17
+ return (character.name.toLowerCase().includes(query) ||
18
+ character.id.toLowerCase().includes(query) ||
19
+ (character.description && character.description.toLowerCase().includes(query)));
20
+ });
21
+ // Close dropdown when clicking outside
22
+ useEffect(() => {
23
+ function handleClickOutside(event) {
24
+ if (containerRef.current && !containerRef.current.contains(event.target)) {
25
+ setIsOpen(false);
26
+ setSearchQuery('');
27
+ }
28
+ }
29
+ if (isOpen) {
30
+ document.addEventListener('mousedown', handleClickOutside);
31
+ // Focus input when dropdown opens
32
+ setTimeout(() => inputRef.current?.focus(), 0);
33
+ }
34
+ return () => {
35
+ document.removeEventListener('mousedown', handleClickOutside);
36
+ };
37
+ }, [isOpen]);
38
+ const handleSelect = (characterId) => {
39
+ onSelect(characterId);
40
+ setIsOpen(false);
41
+ setSearchQuery('');
42
+ };
43
+ const handleClear = (e) => {
44
+ e.stopPropagation();
45
+ onSelect(undefined);
46
+ setIsOpen(false);
47
+ setSearchQuery('');
48
+ };
49
+ // Compact mode for conditional blocks
50
+ if (compact) {
51
+ return (React.createElement("div", { ref: containerRef, className: `relative ${className}` },
52
+ React.createElement("button", { type: "button", onClick: () => setIsOpen(!isOpen), className: "flex items-center gap-1.5 bg-df-elevated border border-df-control-border rounded px-1.5 py-0.5 text-[10px] text-df-text-primary hover:border-df-control-hover transition-colors" }, selectedCharacter ? (React.createElement(React.Fragment, null,
53
+ React.createElement("span", { className: "text-sm flex-shrink-0" }, selectedCharacter.avatar || '👤'),
54
+ React.createElement("span", { className: "text-[10px] truncate max-w-[80px]" }, selectedCharacter.name),
55
+ React.createElement("button", { onClick: handleClear, className: "flex-shrink-0 p-0.5 hover:bg-df-control-hover rounded", title: "Clear character" },
56
+ React.createElement(X, { size: 10, className: "text-df-text-secondary" })))) : (React.createElement(React.Fragment, null,
57
+ React.createElement(User, { size: 10, className: "text-df-text-secondary flex-shrink-0" }),
58
+ React.createElement("span", { className: "text-[10px] text-df-text-secondary" }, placeholder)))),
59
+ isOpen && (React.createElement("div", { className: "absolute z-50 mt-1 w-48 bg-df-sidebar-bg border border-df-sidebar-border rounded-lg shadow-xl max-h-48 overflow-hidden flex flex-col" },
60
+ React.createElement("div", { className: "p-1.5 border-b border-df-sidebar-border" },
61
+ React.createElement("div", { className: "relative" },
62
+ React.createElement(Search, { size: 12, className: "absolute left-1.5 top-1/2 -translate-y-1/2 text-df-text-secondary" }),
63
+ React.createElement("input", { ref: inputRef, type: "text", value: searchQuery, onChange: (e) => setSearchQuery(e.target.value), placeholder: "Search...", className: "w-full bg-df-elevated border border-df-control-border rounded px-1.5 py-1 pl-6 text-[10px] text-df-text-primary placeholder:text-df-text-tertiary focus:border-df-control-hover outline-none" }))),
64
+ React.createElement("div", { className: "overflow-y-auto" }, filteredCharacters.length === 0 ? (React.createElement("div", { className: "p-2 text-[10px] text-df-text-secondary text-center" }, searchQuery ? 'No matches' : 'No characters')) : (filteredCharacters.map(([id, character]) => (React.createElement("button", { key: id, type: "button", onClick: () => handleSelect(id), className: `w-full flex items-center gap-1.5 px-2 py-1 text-left hover:bg-df-elevated transition-colors ${selectedCharacterId === id ? 'bg-df-npc-selected/20' : ''}` },
65
+ React.createElement("span", { className: "text-sm flex-shrink-0" }, character.avatar || '👤'),
66
+ React.createElement("span", { className: "text-[10px] text-df-text-primary truncate flex-1" }, character.name),
67
+ selectedCharacterId === id && (React.createElement("div", { className: "flex-shrink-0 w-1.5 h-1.5 rounded-full bg-df-npc-selected" })))))))))));
68
+ }
69
+ // Full mode (existing)
70
+ return (React.createElement("div", { ref: containerRef, className: `relative ${className}` },
71
+ React.createElement("button", { type: "button", onClick: () => setIsOpen(!isOpen), className: "w-full flex items-center gap-2 bg-df-elevated border border-df-control-border rounded px-2 py-1.5 text-sm text-df-text-primary hover:border-df-control-hover transition-colors" }, selectedCharacter ? (React.createElement(React.Fragment, null,
72
+ React.createElement("span", { className: "text-lg flex-shrink-0" }, selectedCharacter.avatar || '👤'),
73
+ React.createElement("span", { className: "flex-1 text-left truncate" }, selectedCharacter.name),
74
+ React.createElement("button", { onClick: handleClear, className: "flex-shrink-0 p-0.5 hover:bg-df-control-hover rounded", title: "Clear character" },
75
+ React.createElement(X, { size: 12, className: "text-df-text-secondary" })))) : (React.createElement(React.Fragment, null,
76
+ React.createElement(User, { size: 14, className: "text-df-text-secondary flex-shrink-0" }),
77
+ React.createElement("span", { className: "flex-1 text-left text-df-text-secondary" }, placeholder)))),
78
+ isOpen && (React.createElement("div", { className: "absolute z-50 mt-1 w-full bg-df-sidebar-bg border border-df-sidebar-border rounded-lg shadow-xl max-h-64 overflow-hidden flex flex-col" },
79
+ React.createElement("div", { className: "p-2 border-b border-df-sidebar-border" },
80
+ React.createElement("div", { className: "relative" },
81
+ React.createElement(Search, { size: 14, className: "absolute left-2 top-1/2 -translate-y-1/2 text-df-text-secondary" }),
82
+ React.createElement("input", { ref: inputRef, type: "text", value: searchQuery, onChange: (e) => setSearchQuery(e.target.value), placeholder: "Search characters...", className: "w-full bg-df-elevated border border-df-control-border rounded px-2 py-1.5 pl-7 text-sm text-df-text-primary placeholder:text-df-text-tertiary focus:border-df-control-hover outline-none" }))),
83
+ React.createElement("div", { className: "overflow-y-auto" }, filteredCharacters.length === 0 ? (React.createElement("div", { className: "p-3 text-sm text-df-text-secondary text-center" }, searchQuery ? 'No characters found' : 'No characters available')) : (filteredCharacters.map(([id, character]) => (React.createElement("button", { key: id, type: "button", onClick: () => handleSelect(id), className: `w-full flex items-center gap-2 px-3 py-2 text-left hover:bg-df-elevated transition-colors ${selectedCharacterId === id ? 'bg-df-npc-selected/20' : ''}` },
84
+ React.createElement("span", { className: "text-lg flex-shrink-0" }, character.avatar || '👤'),
85
+ React.createElement("div", { className: "flex-1 min-w-0" },
86
+ React.createElement("div", { className: "text-sm text-df-text-primary font-medium truncate" }, character.name),
87
+ character.description && (React.createElement("div", { className: "text-xs text-df-text-secondary truncate" }, character.description))),
88
+ selectedCharacterId === id && (React.createElement("div", { className: "flex-shrink-0 w-2 h-2 rounded-full bg-df-npc-selected" })))))))))));
89
+ }
@@ -2,10 +2,12 @@ import React from 'react';
2
2
  import { NodeProps } from 'reactflow';
3
3
  import { DialogueNode } from '../types';
4
4
  import { FlagSchema } from '../types/flags';
5
+ import { Character } from '../types/characters';
5
6
  import { LayoutDirection } from '../utils/layout';
6
7
  interface ConditionalNodeData {
7
8
  node: DialogueNode;
8
9
  flagSchema?: FlagSchema;
10
+ characters?: Record<string, Character>;
9
11
  isDimmed?: boolean;
10
12
  isInPath?: boolean;
11
13
  layoutDirection?: LayoutDirection;
@@ -1,10 +1,10 @@
1
1
  import React, { useEffect, useRef, useState } from 'react';
2
2
  import { Handle, Position, useUpdateNodeInternals } from 'reactflow';
3
- import { GitBranch, Play, Flag } from 'lucide-react';
3
+ import { GitBranch, Play, Flag, Hash, Code } from 'lucide-react';
4
4
  // Color scheme for conditional block edges
5
5
  const CONDITIONAL_COLORS = ['#3b82f6', '#8b5cf6', '#06b6d4', '#22c55e', '#f59e0b'];
6
6
  export function ConditionalNodeV2({ data, selected }) {
7
- const { node, flagSchema, isDimmed, isInPath, layoutDirection = 'TB', isStartNode, isEndNode } = data;
7
+ const { node, flagSchema, characters = {}, isDimmed, isInPath, layoutDirection = 'TB', isStartNode, isEndNode } = data;
8
8
  const blocks = node.conditionalBlocks || [];
9
9
  const updateNodeInternals = useUpdateNodeInternals();
10
10
  const headerRef = useRef(null);
@@ -13,6 +13,7 @@ export function ConditionalNodeV2({ data, selected }) {
13
13
  // Handle positions based on layout direction
14
14
  const isHorizontal = layoutDirection === 'LR';
15
15
  const targetPosition = isHorizontal ? Position.Left : Position.Top;
16
+ const sourcePosition = isHorizontal ? Position.Right : Position.Bottom;
16
17
  // Calculate handle positions based on actual rendered heights
17
18
  useEffect(() => {
18
19
  if (headerRef.current && blocks.length > 0) {
@@ -49,23 +50,41 @@ export function ConditionalNodeV2({ data, selected }) {
49
50
  : isEndNode
50
51
  ? 'border-df-end shadow-md'
51
52
  : 'border-df-conditional-border';
52
- return (React.createElement("div", { className: `rounded-lg border-2 transition-all duration-300 ${borderClass} ${isInPath ? 'border-df-conditional-border/70' : ''} bg-df-conditional-bg min-w-[200px] relative`, style: isDimmed ? { opacity: 0.35, filter: 'saturate(0.3)' } : undefined },
53
- isStartNode && (React.createElement("div", { className: "absolute -top-2 -left-2 bg-df-start text-df-text-primary text-[8px] font-bold px-1.5 py-0.5 rounded-full flex items-center gap-0.5 shadow-lg z-10" },
54
- React.createElement(Play, { size: 8, fill: "currentColor" }),
55
- " START")),
56
- isEndNode && (React.createElement("div", { className: "absolute -top-2 -right-2 bg-df-end text-df-text-primary text-[8px] font-bold px-1.5 py-0.5 rounded-full flex items-center gap-0.5 shadow-lg z-10" },
57
- React.createElement(Flag, { size: 8 }),
58
- " END")),
53
+ // Header background for conditional nodes
54
+ const headerBgClass = isStartNode
55
+ ? 'bg-df-start-bg'
56
+ : isEndNode
57
+ ? 'bg-df-end-bg'
58
+ : 'bg-df-conditional-header';
59
+ return (React.createElement("div", { className: `rounded-lg border-2 transition-all duration-300 ${borderClass} ${isInPath ? 'border-df-conditional-border/70' : ''} bg-df-conditional-bg min-w-[320px] max-w-[450px] relative overflow-hidden`, style: isDimmed ? { opacity: 0.35, filter: 'saturate(0.3)' } : undefined },
59
60
  React.createElement(Handle, { type: "target", position: targetPosition, className: "!bg-df-control-bg !border-df-control-border !w-4 !h-4 !rounded-full" }),
60
- React.createElement("div", { ref: headerRef, className: "px-3 py-1.5 border-b border-df-control-border bg-df-conditional-header flex items-center gap-2 rounded-t-lg" },
61
- React.createElement(GitBranch, { size: 12, className: "text-df-conditional-border" }),
62
- React.createElement("span", { className: "text-[10px] font-mono text-df-text-secondary truncate flex-1" }, node.id),
63
- React.createElement("span", { className: "text-[10px] text-df-conditional-border" }, "IF/ELSE")),
64
- React.createElement("div", { className: "py-1" }, blocks.map((block, idx) => {
61
+ React.createElement("div", { ref: headerRef, className: `${headerBgClass} border-b-2 border-df-conditional-border px-3 py-2.5 flex items-center gap-3 relative` },
62
+ React.createElement("div", { className: "w-14 h-14 rounded-full bg-df-conditional-bg border-[3px] border-df-conditional-border flex items-center justify-center shadow-lg flex-shrink-0" },
63
+ React.createElement(Code, { size: 20, className: "text-df-conditional-selected" })),
64
+ React.createElement("div", { className: "flex-1 min-w-0" },
65
+ React.createElement("h3", { className: "text-base font-bold text-df-text-primary truncate leading-tight" }, "Conditional Logic")),
66
+ React.createElement("div", { className: "flex items-center gap-2 flex-shrink-0" },
67
+ React.createElement("div", { className: "flex items-center gap-1 px-2 py-1 rounded bg-df-base/50 border border-df-control-border", title: `Node ID: ${node.id}` },
68
+ React.createElement(Hash, { size: 12, className: "text-df-text-secondary" }),
69
+ React.createElement("span", { className: "text-[10px] font-mono text-df-text-secondary" }, node.id)),
70
+ React.createElement("div", { className: "flex items-center gap-1 px-2 py-1 rounded bg-df-conditional-selected/20 border border-df-conditional-selected/50", title: "Conditional Node" },
71
+ React.createElement(GitBranch, { size: 14, className: "text-df-conditional-selected" }),
72
+ React.createElement("span", { className: "text-[10px] font-semibold text-df-conditional-selected" }, "IF/ELSE"))),
73
+ isStartNode && (React.createElement("div", { className: "absolute top-1 right-1 bg-df-start text-df-text-primary text-[8px] font-bold px-1.5 py-0.5 rounded flex items-center gap-0.5 shadow-lg z-20" },
74
+ React.createElement(Play, { size: 8, fill: "currentColor" }),
75
+ " START")),
76
+ isEndNode && (React.createElement("div", { className: "absolute top-1 right-1 bg-df-end text-df-text-primary text-[8px] font-bold px-1.5 py-0.5 rounded flex items-center gap-0.5 shadow-lg z-20" },
77
+ React.createElement(Flag, { size: 8 }),
78
+ " END"))),
79
+ React.createElement("div", { className: "px-4 py-3" }, blocks.map((block, idx) => {
65
80
  const color = CONDITIONAL_COLORS[idx % CONDITIONAL_COLORS.length];
66
81
  const blockType = block.type === 'if' ? 'IF' : block.type === 'elseif' ? 'ELSE IF' : 'ELSE';
67
- return (React.createElement("div", { key: block.id, ref: el => { blockRefs.current[idx] = el; }, className: "px-3 py-1.5 border-b border-df-control-border last:border-b-0" },
68
- React.createElement("div", { className: "flex items-center gap-2 mb-1" },
82
+ // Get character if characterId is set
83
+ const character = block.characterId ? characters[block.characterId] : undefined;
84
+ const displayName = character ? character.name : (block.speaker || undefined);
85
+ const avatar = character?.avatar || '👤';
86
+ return (React.createElement("div", { key: block.id, ref: el => { blockRefs.current[idx] = el; }, className: "px-3 py-2 border-b border-df-control-border last:border-b-0" },
87
+ React.createElement("div", { className: "flex items-center gap-2 mb-1.5" },
69
88
  React.createElement("span", { className: "text-[9px] px-1.5 py-0.5 rounded bg-df-base text-df-text-primary font-semibold" }, blockType),
70
89
  block.condition && block.condition.length > 0 && (React.createElement("span", { className: "text-[9px] text-df-text-secondary font-mono truncate flex-1" }, block.condition.map((c) => {
71
90
  const varName = `$${c.flag}`;
@@ -85,27 +104,29 @@ export function ConditionalNodeV2({ data, selected }) {
85
104
  }
86
105
  return '';
87
106
  }).filter(c => c).join(' and ').slice(0, 30)))),
88
- block.speaker && (React.createElement("div", { className: "text-[9px] text-df-conditional-border font-medium" }, block.speaker)),
89
- React.createElement("div", { className: "text-[10px] text-df-text-primary line-clamp-1 bg-df-base border border-df-control-border rounded px-2 py-1 mt-1" },
107
+ displayName && (React.createElement("div", { className: "flex items-center gap-1.5 mb-1.5" },
108
+ React.createElement("span", { className: "text-sm flex-shrink-0" }, avatar),
109
+ React.createElement("span", { className: "text-[10px] text-df-conditional-selected font-medium" }, displayName))),
110
+ React.createElement("div", { className: "text-xs text-df-text-primary line-clamp-2 bg-df-elevated border border-df-control-border rounded px-3 py-1.5" },
90
111
  "\"",
91
- block.content.slice(0, 40) + (block.content.length > 40 ? '...' : ''),
112
+ block.content.slice(0, 60) + (block.content.length > 60 ? '...' : ''),
92
113
  "\""),
93
114
  React.createElement(Handle, { type: "source", position: Position.Right, id: `block-${idx}`, style: {
94
115
  top: `${handlePositions[idx] || 0}px`,
95
116
  right: -8,
96
- }, className: "!bg-[#2a2a3e] !border-[#4a4a6a] !w-3 !h-3 !rounded-full hover:!border-blue-400 hover:!bg-blue-400/20" })));
117
+ }, className: "!bg-df-control-bg !border-df-control-border !w-3 !h-3 !rounded-full hover:!border-df-conditional-selected hover:!bg-df-conditional-selected/20" })));
97
118
  })),
98
- node.setFlags && node.setFlags.length > 0 && (React.createElement("div", { className: "px-3 py-1 border-t border-[#2a2a3e] flex flex-wrap gap-1" }, node.setFlags.map(flagId => {
119
+ node.setFlags && node.setFlags.length > 0 && (React.createElement("div", { className: "px-4 py-2 border-t border-df-control-border flex flex-wrap gap-1" }, node.setFlags.map(flagId => {
99
120
  const flag = flagSchema?.flags.find(f => f.id === flagId);
100
121
  const flagType = flag?.type || 'dialogue';
101
- const colorClass = flagType === 'dialogue' ? 'bg-gray-500/20 text-gray-400 border-gray-500/30' :
102
- flagType === 'quest' ? 'bg-blue-500/20 text-blue-400 border-blue-500/30' :
103
- flagType === 'achievement' ? 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30' :
104
- flagType === 'item' ? 'bg-green-500/20 text-green-400 border-green-500/30' :
105
- flagType === 'stat' ? 'bg-purple-500/20 text-purple-400 border-purple-500/30' :
106
- flagType === 'title' ? 'bg-pink-500/20 text-pink-400 border-pink-500/30' :
107
- flagType === 'global' ? 'bg-orange-500/20 text-orange-400 border-orange-500/30' :
108
- 'bg-gray-500/20 text-gray-400 border-gray-500/30';
109
- return (React.createElement("span", { key: flagId, className: `text-[8px] px-1 py-0.5 rounded border ${colorClass}`, title: flag?.name || flagId }, flagType === 'dialogue' ? 't' : flagType[0]));
122
+ const colorClass = flagType === 'dialogue' ? 'bg-df-flag-dialogue-bg text-df-flag-dialogue border-df-flag-dialogue' :
123
+ flagType === 'quest' ? 'bg-df-flag-quest-bg text-df-flag-quest border-df-flag-quest' :
124
+ flagType === 'achievement' ? 'bg-df-flag-achievement-bg text-df-flag-achievement border-df-flag-achievement' :
125
+ flagType === 'item' ? 'bg-df-flag-item-bg text-df-flag-item border-df-flag-item' :
126
+ flagType === 'stat' ? 'bg-df-flag-stat-bg text-df-flag-stat border-df-flag-stat' :
127
+ flagType === 'title' ? 'bg-df-flag-title-bg text-df-flag-title border-df-flag-title' :
128
+ flagType === 'global' ? 'bg-df-flag-global-bg text-df-flag-global border-df-flag-global' :
129
+ 'bg-df-flag-dialogue-bg text-df-flag-dialogue border-df-flag-dialogue';
130
+ return (React.createElement("span", { key: flagId, className: `text-[8px] px-1.5 py-0.5 rounded-full border ${colorClass}`, title: flag?.name || flagId }, flagType === 'dialogue' ? 't' : flagType[0]));
110
131
  })))));
111
132
  }
@@ -8,9 +8,11 @@ import React from 'react';
8
8
  import 'reactflow/dist/style.css';
9
9
  import { DialogueEditorProps } from '../types';
10
10
  import { FlagSchema } from '../types/flags';
11
+ import { Character } from '../types/characters';
11
12
  type ViewMode = 'graph' | 'yarn' | 'play';
12
13
  export declare function DialogueEditorV2(props: DialogueEditorProps & {
13
14
  flagSchema?: FlagSchema;
15
+ characters?: Record<string, Character>;
14
16
  initialViewMode?: ViewMode;
15
17
  viewMode?: ViewMode;
16
18
  onViewModeChange?: (mode: ViewMode) => void;
@@ -32,7 +32,7 @@ const edgeTypes = {
32
32
  choice: ChoiceEdgeV2,
33
33
  default: NPCEdgeV2, // Use custom component for NPC edges instead of React Flow default
34
34
  };
35
- function DialogueEditorV2Internal({ dialogue, onChange, onExportYarn, onExportJSON, className = '', showTitleEditor = true, flagSchema, initialViewMode = 'graph', viewMode: controlledViewMode, onViewModeChange, layoutStrategy: propLayoutStrategy = 'dagre', // Accept from parent
35
+ function DialogueEditorV2Internal({ dialogue, onChange, onExportYarn, onExportJSON, className = '', showTitleEditor = true, flagSchema, characters = {}, initialViewMode = 'graph', viewMode: controlledViewMode, onViewModeChange, layoutStrategy: propLayoutStrategy = 'dagre', // Accept from parent
36
36
  onLayoutStrategyChange, onOpenFlagManager, onOpenGuide, onLoadExampleDialogue, onLoadExampleFlags,
37
37
  // Event hooks
38
38
  onNodeAdd, onNodeDelete, onNodeUpdate, onConnect: onConnectHook, onDisconnect, onNodeSelect, onNodeDoubleClick: onNodeDoubleClickHook, }) {
@@ -48,6 +48,8 @@ onNodeAdd, onNodeDelete, onNodeUpdate, onConnect: onConnectHook, onDisconnect, o
48
48
  const [layoutDirection, setLayoutDirection] = useState('TB');
49
49
  const layoutStrategy = propLayoutStrategy; // Use prop instead of state
50
50
  const [autoOrganize, setAutoOrganize] = useState(false); // Auto-layout on changes
51
+ // Track if we've made a direct React Flow update to avoid unnecessary conversions
52
+ const directUpdateRef = useRef(null);
51
53
  const [showPathHighlight, setShowPathHighlight] = useState(true); // Toggle path highlighting
52
54
  const [showBackEdges, setShowBackEdges] = useState(true); // Toggle back-edge styling
53
55
  const [showLayoutMenu, setShowLayoutMenu] = useState(false);
@@ -138,13 +140,20 @@ onNodeAdd, onNodeDelete, onNodeUpdate, onConnect: onConnectHook, onDisconnect, o
138
140
  return { edgesToSelectedNode: edgesOnPath, nodeDepths: nodeDepthMap };
139
141
  }, [selectedNodeId, dialogue]);
140
142
  // Update nodes/edges when dialogue changes externally
143
+ // Skip conversion if we just made a direct React Flow update (for simple text changes)
141
144
  React.useEffect(() => {
142
145
  if (dialogue) {
146
+ // If we just updated a node directly in React Flow, skip full conversion
147
+ // The direct update already handled the visual change
148
+ if (directUpdateRef.current) {
149
+ directUpdateRef.current = null; // Clear the flag
150
+ return; // Skip conversion - React Flow is already updated
151
+ }
143
152
  const { nodes: newNodes, edges: newEdges } = convertDialogueTreeToReactFlow(dialogue, layoutDirection);
144
153
  setNodes(newNodes);
145
154
  setEdges(newEdges);
146
155
  }
147
- }, [dialogue]);
156
+ }, [dialogue, layoutDirection]);
148
157
  // Calculate end nodes (nodes with no outgoing connections)
149
158
  const endNodeIds = useMemo(() => {
150
159
  if (!dialogue)
@@ -160,7 +169,7 @@ onNodeAdd, onNodeDelete, onNodeUpdate, onConnect: onConnectHook, onDisconnect, o
160
169
  });
161
170
  return ends;
162
171
  }, [dialogue]);
163
- // Add flagSchema, dim state, and layout direction to node data
172
+ // Add flagSchema, characters, dim state, and layout direction to node data
164
173
  const nodesWithFlags = useMemo(() => {
165
174
  const hasSelection = selectedNodeId !== null && showPathHighlight;
166
175
  const startNodeId = dialogue?.startNodeId;
@@ -176,6 +185,7 @@ onNodeAdd, onNodeDelete, onNodeUpdate, onConnect: onConnectHook, onDisconnect, o
176
185
  data: {
177
186
  ...node.data,
178
187
  flagSchema,
188
+ characters, // Pass characters to all nodes including conditional
179
189
  isDimmed,
180
190
  isInPath,
181
191
  layoutDirection,
@@ -184,7 +194,7 @@ onNodeAdd, onNodeDelete, onNodeUpdate, onConnect: onConnectHook, onDisconnect, o
184
194
  },
185
195
  };
186
196
  });
187
- }, [nodes, flagSchema, nodeDepths, selectedNodeId, layoutDirection, showPathHighlight, dialogue, endNodeIds]);
197
+ }, [nodes, flagSchema, characters, nodeDepths, selectedNodeId, layoutDirection, showPathHighlight, dialogue, endNodeIds]);
188
198
  if (!dialogue) {
189
199
  return (React.createElement("div", { className: `dialogue-editor-v2-empty ${className}` },
190
200
  React.createElement("p", null, "No dialogue loaded. Please provide a dialogue tree.")));
@@ -746,6 +756,33 @@ onNodeAdd, onNodeDelete, onNodeUpdate, onConnect: onConnectHook, onDisconnect, o
746
756
  // Handle node updates
747
757
  const handleUpdateNode = useCallback((nodeId, updates) => {
748
758
  const updatedNode = { ...dialogue.nodes[nodeId], ...updates };
759
+ // Check if this is a "simple" update (just text/content changes, not structural)
760
+ // Simple updates: speaker, content, characterId (non-structural properties)
761
+ // Structural updates: choices, conditionalBlocks, nextNodeId (affect edges/connections)
762
+ const isSimpleUpdate = Object.keys(updates).every(key => ['speaker', 'content', 'characterId', 'setFlags'].includes(key));
763
+ if (isSimpleUpdate && reactFlowInstance) {
764
+ // For simple updates, update React Flow directly without full tree conversion
765
+ // This is much faster and avoids expensive recalculations
766
+ const allNodes = reactFlowInstance.getNodes();
767
+ const nodeToUpdate = allNodes.find(n => n.id === nodeId);
768
+ if (nodeToUpdate) {
769
+ // Mark that we're doing a direct update to skip full conversion
770
+ directUpdateRef.current = nodeId;
771
+ // Update the node data directly in React Flow
772
+ const updatedReactFlowNode = {
773
+ ...nodeToUpdate,
774
+ data: {
775
+ ...nodeToUpdate.data,
776
+ node: updatedNode, // Update the dialogue node in the data
777
+ },
778
+ };
779
+ // Update just this node in React Flow
780
+ const updatedNodes = allNodes.map(n => n.id === nodeId ? updatedReactFlowNode : n);
781
+ reactFlowInstance.setNodes(updatedNodes);
782
+ }
783
+ }
784
+ // Always update the dialogue tree (source of truth) - but this triggers full conversion
785
+ // The useEffect will handle the full conversion, but React Flow is already updated above
749
786
  onChange({
750
787
  ...dialogue,
751
788
  nodes: {
@@ -755,7 +792,7 @@ onNodeAdd, onNodeDelete, onNodeUpdate, onConnect: onConnectHook, onDisconnect, o
755
792
  });
756
793
  // Call onNodeUpdate hook
757
794
  onNodeUpdate?.(nodeId, updates);
758
- }, [dialogue, onChange, onNodeUpdate]);
795
+ }, [dialogue, onChange, onNodeUpdate, reactFlowInstance]);
759
796
  // Handle choice updates
760
797
  const handleAddChoice = useCallback((nodeId) => {
761
798
  const updated = addChoiceToNode(dialogue.nodes[nodeId]);
@@ -1075,7 +1112,7 @@ onNodeAdd, onNodeDelete, onNodeUpdate, onConnect: onConnectHook, onDisconnect, o
1075
1112
  " Delete"))));
1076
1113
  })(),
1077
1114
  React.createElement("button", { onClick: () => setNodeContextMenu(null), className: "w-full px-4 py-1.5 text-xs text-df-text-secondary hover:text-df-text-primary border-t border-df-control-border mt-1" }, "Cancel")))))),
1078
- selectedNode && (React.createElement(NodeEditor, { node: selectedNode, dialogue: dialogue, onUpdate: (updates) => handleUpdateNode(selectedNode.id, updates), onFocusNode: (nodeId) => {
1115
+ selectedNode && (React.createElement(NodeEditor, { node: selectedNode, dialogue: dialogue, characters: characters, onUpdate: (updates) => handleUpdateNode(selectedNode.id, updates), onFocusNode: (nodeId) => {
1079
1116
  const targetNode = nodes.find(n => n.id === nodeId);
1080
1117
  if (targetNode && reactFlowInstance) {
1081
1118
  // Set selectedNodeId first so NodeEditor updates
@@ -363,9 +363,9 @@ const [gameState, setGameState] = useState<GameState>({
363
363
  }}
364
364
  flagSchema={flagSchema}
365
365
  // Event hooks
366
- onNodeAdd={(node) => console.log('Node added:', node.id)}
367
- onNodeDelete={(nodeId) => console.log('Node deleted:', nodeId)}
368
- onConnect={(source, target) => console.log('Connected:', source, '->', target)}
366
+ onNodeAdd={(node) => {/* Example: handle node add */}}
367
+ onNodeDelete={(nodeId) => {/* Example: handle node delete */}}
368
+ onConnect={(source, target) => {/* Example: handle connect */}}
369
369
  />`, language: "typescript" }),
370
370
  React.createElement("h3", { className: "text-lg font-semibold mt-6 mb-2 text-white" }, "6. Define Game State"),
371
371
  React.createElement("p", { className: "text-gray-400 text-sm mb-2" }, "Game state can be any JSON object. Flags should represent the dialogue-relevant portion of your game state."),
@@ -429,21 +429,21 @@ const gameState: GameState = {
429
429
  }}
430
430
  // Event hooks
431
431
  onNodeEnter={(nodeId, node) => {
432
- console.log('Entered node:', nodeId, node);
432
+ // Example: handle node enter
433
433
  // Trigger animations, sound effects, etc.
434
434
  }}
435
435
  onNodeExit={(nodeId, node) => {
436
- console.log('Exited node:', nodeId, node);
436
+ // Example: handle node exit
437
437
  }}
438
438
  onChoiceSelect={(nodeId, choice) => {
439
- console.log('Selected choice:', choice.text);
439
+ // Example: handle choice select
440
440
  // Track player decisions
441
441
  }}
442
442
  onDialogueStart={() => {
443
- console.log('Dialogue started');
443
+ // Example: handle dialogue start
444
444
  }}
445
445
  onDialogueEnd={() => {
446
- console.log('Dialogue ended');
446
+ // Example: handle dialogue end
447
447
  }}
448
448
  />`, language: "typescript" }),
449
449
  React.createElement("h3", { className: "text-lg font-semibold mt-6 mb-2 text-white" }, "Complete Example"),
@@ -502,23 +502,23 @@ const dialogue = importFromYarn(yarnFile, 'Merchant');
502
502
  flagSchema={flagSchema}
503
503
  // Event hooks
504
504
  onNodeAdd={(node) => {
505
- console.log('Node added:', node.id);
505
+ // Example: handle node add
506
506
  // Track node creation
507
507
  }}
508
508
  onNodeDelete={(nodeId) => {
509
- console.log('Node deleted:', nodeId);
509
+ // Example: handle node delete
510
510
  }}
511
511
  onNodeUpdate={(nodeId, updates) => {
512
- console.log('Node updated:', nodeId, updates);
512
+ // Example: handle node update
513
513
  }}
514
514
  onConnect={(sourceId, targetId, sourceHandle) => {
515
- console.log('Connected:', sourceId, '->', targetId);
515
+ // Example: handle connect
516
516
  }}
517
517
  onDisconnect={(edgeId, sourceId, targetId) => {
518
- console.log('Disconnected:', sourceId, '->', targetId);
518
+ // Example: handle disconnect
519
519
  }}
520
520
  onNodeSelect={(nodeId) => {
521
- console.log('Node selected:', nodeId);
521
+ // Example: handle node select
522
522
  }}
523
523
  />
524
524
 
@@ -534,11 +534,11 @@ const dialogue = importFromYarn(yarnFile, 'Merchant');
534
534
  }));
535
535
  }}
536
536
  onNodeEnter={(nodeId, node) => {
537
- console.log('Entered node:', nodeId);
537
+ // Example: handle node enter
538
538
  // Play animations, sound effects
539
539
  }}
540
540
  onChoiceSelect={(nodeId, choice) => {
541
- console.log('Selected:', choice.text);
541
+ // Example: handle choice select
542
542
  // Track player decisions
543
543
  }}
544
544
  />`, language: "typescript" }),
@@ -1008,6 +1008,89 @@ await saveFile('dialogue.yarn', newYarn);`))))
1008
1008
  React.createElement("strong", { className: "text-white text-xs" }, "Square Selection"),
1009
1009
  React.createElement("p", { className: "text-gray-400 text-xs mt-1" }, "Selection box doesn't always capture all nodes within the selection area (deprioritized)")))))))
1010
1010
  },
1011
+ characters: {
1012
+ title: 'Characters',
1013
+ content: (React.createElement("div", { className: "space-y-4 text-sm" },
1014
+ React.createElement("p", { className: "text-gray-300" }, "Dialogue Forge supports character assignment for NPC and Player nodes. Characters are defined in your game state and can be selected from a searchable dropdown."),
1015
+ React.createElement("h3", { className: "text-lg font-semibold mt-6 mb-2 text-white" }, "Character System"),
1016
+ React.createElement("p", { className: "text-gray-300 mb-3" }, "Each node (NPC or Player) can be assigned a character from your game state. When a character is assigned:"),
1017
+ React.createElement("ul", { className: "list-disc list-inside space-y-1 text-sm ml-2 text-gray-300" },
1018
+ React.createElement("li", null, "The character's avatar and name are displayed on the node in the graph"),
1019
+ React.createElement("li", null, "The character name is used as the speaker name"),
1020
+ React.createElement("li", null, "You can still override with a custom speaker name if needed")),
1021
+ React.createElement("h3", { className: "text-lg font-semibold mt-6 mb-2 text-white" }, "Game State Structure"),
1022
+ React.createElement("p", { className: "text-gray-300 mb-3" },
1023
+ "Characters should be defined in your game state under the ",
1024
+ React.createElement("code", { className: "bg-[#0d0d14] px-1 rounded" }, "characters"),
1025
+ " property:"),
1026
+ React.createElement(CodeBlock, { code: `interface GameState {
1027
+ flags?: FlagState;
1028
+ characters?: {
1029
+ [characterId: string]: Character;
1030
+ };
1031
+ }
1032
+
1033
+ interface Character {
1034
+ id: string;
1035
+ name: string;
1036
+ avatar?: string; // URL or emoji (e.g., "👤", "🧙", "/avatars/wizard.png")
1037
+ description?: string;
1038
+ }`, language: "typescript" }),
1039
+ React.createElement("h3", { className: "text-lg font-semibold mt-6 mb-2 text-white" }, "Using Characters"),
1040
+ React.createElement("ol", { className: "list-decimal list-inside space-y-2 text-sm ml-2 text-gray-300" },
1041
+ React.createElement("li", null,
1042
+ React.createElement("strong", null, "Define characters"),
1043
+ " in your game state with ",
1044
+ React.createElement("code", { className: "bg-[#0d0d14] px-1 rounded" }, "id"),
1045
+ ", ",
1046
+ React.createElement("code", { className: "bg-[#0d0d14] px-1 rounded" }, "name"),
1047
+ ", and optionally ",
1048
+ React.createElement("code", { className: "bg-[#0d0d14] px-1 rounded" }, "avatar")),
1049
+ React.createElement("li", null,
1050
+ React.createElement("strong", null, "Pass characters"),
1051
+ " to ",
1052
+ React.createElement("code", { className: "bg-[#0d0d14] px-1 rounded" }, "DialogueEditorV2"),
1053
+ " via the ",
1054
+ React.createElement("code", { className: "bg-[#0d0d14] px-1 rounded" }, "characters"),
1055
+ " prop"),
1056
+ React.createElement("li", null,
1057
+ React.createElement("strong", null, "Select a character"),
1058
+ " in the Node Editor using the character dropdown (searchable combobox)"),
1059
+ React.createElement("li", null,
1060
+ React.createElement("strong", null, "View on graph"),
1061
+ " - The character's avatar and name appear on the node")),
1062
+ React.createElement("h3", { className: "text-lg font-semibold mt-6 mb-2 text-white" }, "Example"),
1063
+ React.createElement(CodeBlock, { code: `// Game state with characters
1064
+ const gameState = {
1065
+ flags: { reputation: 50 },
1066
+ characters: {
1067
+ stranger: {
1068
+ id: 'stranger',
1069
+ name: 'Mysterious Stranger',
1070
+ avatar: '👤',
1071
+ description: 'A cloaked figure'
1072
+ },
1073
+ player: {
1074
+ id: 'player',
1075
+ name: 'Player',
1076
+ avatar: '🎮',
1077
+ description: 'The player character'
1078
+ }
1079
+ }
1080
+ };
1081
+
1082
+ // Pass to DialogueEditorV2
1083
+ <DialogueEditorV2
1084
+ dialogue={dialogueTree}
1085
+ characters={gameState.characters}
1086
+ flagSchema={flagSchema}
1087
+ onChange={setDialogueTree}
1088
+ />`, language: "typescript" }),
1089
+ React.createElement("div", { className: "bg-[#1a2a3e] border-l-4 border-blue-500 p-4 rounded mt-4" },
1090
+ React.createElement("p", { className: "text-gray-300 text-xs" },
1091
+ React.createElement("strong", null, "Note:"),
1092
+ " If a character is not assigned, you can still use a custom speaker name. The character system is optional but recommended for consistency across your dialogue system."))))
1093
+ },
1011
1094
  theming: {
1012
1095
  title: 'Theming',
1013
1096
  content: (React.createElement("div", { className: "space-y-4 text-sm" },
@@ -11,11 +11,13 @@
11
11
  import React from 'react';
12
12
  import { NodeProps } from 'reactflow';
13
13
  import { DialogueNode } from '../types';
14
+ import { Character } from '../types/characters';
14
15
  import { FlagSchema } from '../types/flags';
15
16
  import { LayoutDirection } from '../utils/layout';
16
17
  interface NPCNodeData {
17
18
  node: DialogueNode;
18
19
  flagSchema?: FlagSchema;
20
+ characters?: Record<string, Character>;
19
21
  isDimmed?: boolean;
20
22
  isInPath?: boolean;
21
23
  layoutDirection?: LayoutDirection;