@magicborn/dialogue-forge 0.1.0 → 0.1.2
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/demo/app/page.tsx +26 -12
- package/demo/public/logo.svg +14 -1
- package/dist/components/CharacterSelector.d.ts +15 -0
- package/dist/components/CharacterSelector.js +125 -0
- package/dist/components/ConditionalNodeV2.d.ts +2 -0
- package/dist/components/ConditionalNodeV2.js +50 -29
- package/dist/components/DialogueEditorV2.d.ts +2 -0
- package/dist/components/DialogueEditorV2.js +43 -6
- package/dist/components/GuidePanel.js +99 -16
- package/dist/components/NPCNodeV2.d.ts +2 -0
- package/dist/components/NPCNodeV2.js +31 -20
- package/dist/components/NodeEditor.d.ts +3 -1
- package/dist/components/NodeEditor.js +42 -13
- package/dist/components/PlayerNodeV2.d.ts +2 -0
- package/dist/components/PlayerNodeV2.js +58 -39
- package/dist/components/ReactFlowPOC.js +3 -3
- package/dist/esm/components/CharacterSelector.d.ts +15 -0
- package/dist/esm/components/CharacterSelector.js +89 -0
- package/dist/esm/components/ConditionalNodeV2.d.ts +2 -0
- package/dist/esm/components/ConditionalNodeV2.js +51 -30
- package/dist/esm/components/DialogueEditorV2.d.ts +2 -0
- package/dist/esm/components/DialogueEditorV2.js +43 -6
- package/dist/esm/components/GuidePanel.js +99 -16
- package/dist/esm/components/NPCNodeV2.d.ts +2 -0
- package/dist/esm/components/NPCNodeV2.js +32 -21
- package/dist/esm/components/NodeEditor.d.ts +3 -1
- package/dist/esm/components/NodeEditor.js +42 -13
- package/dist/esm/components/PlayerNodeV2.d.ts +2 -0
- package/dist/esm/components/PlayerNodeV2.js +59 -40
- package/dist/esm/components/ReactFlowPOC.js +3 -3
- package/dist/esm/examples/example-characters.d.ts +19 -0
- package/dist/esm/examples/example-characters.js +67 -0
- package/dist/esm/examples/index.d.ts +1 -0
- package/dist/esm/examples/index.js +2 -0
- package/dist/esm/index.d.ts +3 -0
- package/dist/esm/index.js +3 -0
- package/dist/esm/types/characters.d.ts +15 -0
- package/dist/esm/types/characters.js +6 -0
- package/dist/esm/types/game-state.d.ts +2 -0
- package/dist/esm/types/index.d.ts +2 -0
- package/dist/examples/example-characters.d.ts +19 -0
- package/dist/examples/example-characters.js +73 -0
- package/dist/examples/index.d.ts +1 -0
- package/dist/examples/index.js +7 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.js +9 -1
- package/dist/types/characters.d.ts +15 -0
- package/dist/types/characters.js +7 -0
- package/dist/types/game-state.d.ts +2 -0
- package/dist/types/index.d.ts +2 -0
- 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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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:
|
|
61
|
-
React.createElement(
|
|
62
|
-
|
|
63
|
-
React.createElement("
|
|
64
|
-
|
|
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
|
-
|
|
68
|
-
|
|
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
|
-
|
|
89
|
-
|
|
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,
|
|
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-
|
|
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-
|
|
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-
|
|
102
|
-
flagType === 'quest' ? 'bg-
|
|
103
|
-
flagType === 'achievement' ? 'bg-
|
|
104
|
-
flagType === 'item' ? 'bg-
|
|
105
|
-
flagType === 'stat' ? 'bg-
|
|
106
|
-
flagType === 'title' ? 'bg-
|
|
107
|
-
flagType === 'global' ? 'bg-
|
|
108
|
-
'bg-
|
|
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) =>
|
|
367
|
-
onNodeDelete={(nodeId) =>
|
|
368
|
-
onConnect={(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
|
-
|
|
432
|
+
// Example: handle node enter
|
|
433
433
|
// Trigger animations, sound effects, etc.
|
|
434
434
|
}}
|
|
435
435
|
onNodeExit={(nodeId, node) => {
|
|
436
|
-
|
|
436
|
+
// Example: handle node exit
|
|
437
437
|
}}
|
|
438
438
|
onChoiceSelect={(nodeId, choice) => {
|
|
439
|
-
|
|
439
|
+
// Example: handle choice select
|
|
440
440
|
// Track player decisions
|
|
441
441
|
}}
|
|
442
442
|
onDialogueStart={() => {
|
|
443
|
-
|
|
443
|
+
// Example: handle dialogue start
|
|
444
444
|
}}
|
|
445
445
|
onDialogueEnd={() => {
|
|
446
|
-
|
|
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
|
-
|
|
505
|
+
// Example: handle node add
|
|
506
506
|
// Track node creation
|
|
507
507
|
}}
|
|
508
508
|
onNodeDelete={(nodeId) => {
|
|
509
|
-
|
|
509
|
+
// Example: handle node delete
|
|
510
510
|
}}
|
|
511
511
|
onNodeUpdate={(nodeId, updates) => {
|
|
512
|
-
|
|
512
|
+
// Example: handle node update
|
|
513
513
|
}}
|
|
514
514
|
onConnect={(sourceId, targetId, sourceHandle) => {
|
|
515
|
-
|
|
515
|
+
// Example: handle connect
|
|
516
516
|
}}
|
|
517
517
|
onDisconnect={(edgeId, sourceId, targetId) => {
|
|
518
|
-
|
|
518
|
+
// Example: handle disconnect
|
|
519
519
|
}}
|
|
520
520
|
onNodeSelect={(nodeId) => {
|
|
521
|
-
|
|
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
|
-
|
|
537
|
+
// Example: handle node enter
|
|
538
538
|
// Play animations, sound effects
|
|
539
539
|
}}
|
|
540
540
|
onChoiceSelect={(nodeId, choice) => {
|
|
541
|
-
|
|
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;
|