feat: subtree navigation, i18n cleanup, UI polish (centered node text, smooth theme transition, remapped icons)

This commit is contained in:
2026-08-05 00:57:31 +08:00
parent 77740bf45f
commit 35a0319a8d
11 changed files with 200 additions and 96 deletions

View File

@@ -33,7 +33,7 @@ import {
Sun,
TreePine,
Undo2,
Upload,
FileUp,
} from 'lucide-react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { BehaviorNodeCard } from './components/BehaviorNodeCard'
@@ -94,7 +94,7 @@ function Editor() {
try {
return { value: documentToXml(treeDocument), error: '' }
} catch (error) {
return { value: '', error: error instanceof Error ? error.message : '无法生成 XML' }
return { value: '', error: error instanceof Error ? error.message : 'Cannot generate XML' }
}
}, [treeDocument])
@@ -158,7 +158,17 @@ function Editor() {
const addNodeAt = useCallback((type: NodeType, position?: { x: number; y: number }) => {
const point = position || screenToFlowPosition({ x: window.innerWidth / 2, y: window.innerHeight / 2 })
const node = createNode(type, point)
commit((current) => ({ ...current, nodes: [...current.nodes, node] }))
commit((current) => {
const next = { ...current, nodes: [...current.nodes, node] }
// Auto-create a tree for new SubTree references
if (type === 'SubTree') {
const treeId = node.data.registrationName
if (treeId && !next.trees?.some((t) => t.id === treeId)) {
next.trees = [...(next.trees || []), { id: treeId, nodes: [], edges: [] }]
}
}
return next
})
setSelectedNodeId(node.id)
setViewMode('canvas')
}, [commit, screenToFlowPosition])
@@ -196,6 +206,16 @@ function Editor() {
window.setTimeout(() => fitView({ padding: 0.2, duration: 320 }), 60)
}, [currentTreeId, fitView])
const navigateToTree = useCallback((treeId: string) => {
commit((current) => {
if (!current.trees?.some((t) => t.id === treeId)) {
return { ...current, trees: [...(current.trees || []), { id: treeId, nodes: [], edges: [] }] }
}
return current
})
switchTree(treeId)
}, [commit, switchTree])
const deleteNode = useCallback((nodeId: string) => {
commit((current) => ({
...current,
@@ -211,10 +231,21 @@ function Editor() {
}, [commit])
const updateNode = useCallback((nodeId: string, data: Partial<BehaviorNode['data']>) => {
commit((current) => ({
commit((current) => {
const node = current.nodes.find((n) => n.id === nodeId)
const next = {
...current,
nodes: current.nodes.map((node) => node.id === nodeId ? { ...node, data: { ...node.data, ...data } } : node),
}))
nodes: current.nodes.map((n) => n.id === nodeId ? { ...n, data: { ...n.data, ...data } } : n),
}
// Auto-create tree when SubTree registrationName changes
if (node && node.data.nodeType === 'SubTree' && data.registrationName) {
const treeId = data.registrationName
if (!next.trees?.some((t) => t.id === treeId)) {
next.trees = [...(next.trees || []), { id: treeId, nodes: [], edges: [] }]
}
}
return next
})
}, [commit])
const onNodesChange = useCallback((changes: NodeChange<BehaviorNode>[]) => {
@@ -228,15 +259,15 @@ function Editor() {
const outgoingCount = treeDocument.edges.filter((edge) => edge.source === source.id).length
const incomingCount = treeDocument.edges.filter((edge) => edge.target === connection.target).length
if (outgoingCount >= maxChildren(source.data.nodeType)) {
showToast(`${source.data.label} 不能再添加子节点`, 'error')
showToast(`${source.data.label} ${t('validation.cannotAddChild')}`, 'error')
return
}
if (incomingCount > 0) {
showToast('一个节点只能有一个父节点', 'error')
showToast(t('validation.singleParent'), 'error')
return
}
if (wouldCreateCycle(treeDocument, connection.source, connection.target)) {
showToast('此连接会形成环路', 'error')
showToast(t('validation.wouldCycle'), 'error')
return
}
commit((current) => ({
@@ -252,7 +283,7 @@ function Editor() {
}, [commit, fitView, treeDocument.nodes.length])
const newProject = useCallback(() => {
if (dirty && !window.confirm('当前工程有未保存更改,仍要新建吗?')) return
if (dirty && !window.confirm(t('confirm.unsavedNew'))) return
replaceDocument(createEmptyDocument())
}, [dirty, replaceDocument])
@@ -263,9 +294,9 @@ function Editor() {
: await pickBrowserFile('.arborflow,.json')
if (!result) return
replaceDocument(parseProject(result.content), window.arborflow ? result.path : null)
showToast('工程已打开')
showToast(t('toast.projectOpened'))
} catch (error) {
showToast(error instanceof Error ? error.message : '打开工程失败', 'error')
showToast(error instanceof Error ? error.message : t('toast.openFailed'), 'error')
}
}, [replaceDocument, showToast])
@@ -276,7 +307,7 @@ function Editor() {
downloadText(`${treeDocument.title || 'behavior-tree'}.arborflow`, content, 'application/json')
savedContentRef.current = content
setDirty(false)
showToast('工程已下载')
showToast(t('toast.projectDownloaded'))
return
}
const result = await window.arborflow.saveText({
@@ -290,9 +321,9 @@ function Editor() {
setProjectPath(result.path)
savedContentRef.current = content
setDirty(false)
showToast('工程已保存')
showToast(t('toast.projectSaved'))
} catch (error) {
showToast(error instanceof Error ? error.message : '保存失败', 'error')
showToast(error instanceof Error ? error.message : t('toast.saveFailed'), 'error')
}
}, [projectPath, showToast, treeDocument])
@@ -305,9 +336,9 @@ function Editor() {
replaceDocument(xmlToDocument(result.content))
savedContentRef.current = ''
setDirty(true)
showToast('XML 已导入')
showToast(t('toast.xmlImported'))
} catch (error) {
showToast(error instanceof Error ? error.message : '导入 XML 失败', 'error')
showToast(error instanceof Error ? error.message : t('toast.importFailed'), 'error')
}
}, [replaceDocument, showToast])
@@ -321,9 +352,9 @@ function Editor() {
const result = await window.arborflow.saveText({ prompt: true, defaultPath: filename, content, filters: XML_FILTERS })
if (!result) return
}
showToast('BehaviorTree.CPP XML 已导出')
showToast(t('toast.xmlExported'))
} catch (error) {
showToast(error instanceof Error ? error.message : '导出 XML 失败', 'error')
showToast(error instanceof Error ? error.message : t('toast.exportFailed'), 'error')
}
}, [showToast, treeDocument])
@@ -350,7 +381,7 @@ function Editor() {
const copyXml = useCallback(async () => {
if (!xmlPreview.value) return
await navigator.clipboard.writeText(xmlPreview.value)
showToast('XML 已复制')
showToast(t('toast.xmlCopied'))
}, [showToast, xmlPreview.value])
useEffect(() => {
@@ -449,19 +480,19 @@ function Editor() {
<div className="brand-mark"><TreePine size={20} /></div>
<div className="brand-copy"><strong>ArborFlow</strong><span>Behavior Tree Studio</span></div>
</div>
<div className="document-title"><span>{treeDocument.title}</span>{dirty && <i title="未保存" />}</div>
<div className="document-title"><span>{treeDocument.title}</span>{dirty && <i title={t('action.unsaved')} />}</div>
<div className="header-actions">
<button className="icon-button" onClick={newProject} title="新建工程 (Ctrl+N)"><FilePlus2 size={17} /></button>
<button className="icon-button" onClick={() => void openProject()} title="打开工程 (Ctrl+O)"><FolderOpen size={17} /></button>
<button className="icon-button" onClick={() => void saveProject(false)} title="保存工程 (Ctrl+S)"><Save size={17} /></button>
<button className="icon-button" onClick={newProject} title={`${t('action.newProject')} (Ctrl+N)`}><FilePlus2 size={17} /></button>
<button className="icon-button" onClick={() => void openProject()} title={`${t('action.openProject')} (Ctrl+O)`}><FolderOpen size={17} /></button>
<button className="icon-button" onClick={() => void saveProject(false)} title={`${t('action.saveProject')} (Ctrl+S)`}><Save size={17} /></button>
<span className="toolbar-separator" />
<button className="icon-button" onClick={undo} disabled={!canUndo} title="撤销 (Ctrl+Z)"><Undo2 size={17} /></button>
<button className="icon-button" onClick={redo} disabled={!canRedo} title="重做 (Ctrl+Shift+Z)"><Redo2 size={17} /></button>
<button className="icon-button" onClick={undo} disabled={!canUndo} title={`${t('action.undo')} (Ctrl+Z)`}><Undo2 size={17} /></button>
<button className="icon-button" onClick={redo} disabled={!canRedo} title={`${t('action.redo')} (Ctrl+Shift+Z)`}><Redo2 size={17} /></button>
<span className="toolbar-separator" />
<button className="icon-button" onClick={importXml} title="导入 XML"><Upload size={17} /></button>
<button className="icon-button" onClick={exportXml} title="导出 XML"><FileDown size={17} /></button>
<button className="icon-button" onClick={importXml} title={t('action.importXml')}><FileUp size={17} /></button>
<button className="icon-button" onClick={exportXml} title={t('action.exportXml')}><FileDown size={17} /></button>
<span className="toolbar-separator" />
<button className={`icon-button ${settingsOpen ? 'active' : ''}`} onClick={() => setSettingsOpen((v) => !v)} title="设置"><Settings size={17} /></button>
<button className={`icon-button ${settingsOpen ? 'active' : ''}`} onClick={() => setSettingsOpen((v) => !v)} title={t('action.settings')}><Settings size={17} /></button>
<button className={`monitor-button ${monitorOpen ? 'active' : ''}`} onClick={() => setMonitorOpen((value) => !value)}><MonitorDot size={16} />Monitor</button>
</div>
</header>
@@ -493,22 +524,22 @@ function Editor() {
</button>
{treeMenuOpen && (
<div className="tree-menu">
<div className="tree-menu-heading">行为树 · {treeGraphs.length}</div>
<div className="tree-menu-heading">{t('tree.heading')} · {treeGraphs.length}</div>
{treeGraphs.map((tree) => (
<button className={tree.id === currentTreeId ? 'active' : ''} key={tree.id} onClick={() => switchTree(tree.id)}>
<span>{tree.id}</span>
<small>{tree.nodes.length} 节点{tree.id === treeDocument.mainTreeId ? ' · MAIN' : ''}</small>
<small>{tree.nodes.length} {t('status.nodes')}{tree.id === treeDocument.mainTreeId ? ' · MAIN' : ''}</small>
</button>
))}
</div>
)}
</div>
<div className="view-switch" aria-label="视图切换">
<button className={viewMode === 'canvas' ? 'active' : ''} onClick={() => setViewMode('canvas')}><TreePine size={14} />画布</button>
<div className="view-switch" aria-label={t('toolbar.viewSwitch')}>
<button className={viewMode === 'canvas' ? 'active' : ''} onClick={() => setViewMode('canvas')}><TreePine size={14} />{t('toolbar.canvas')}</button>
<button className={viewMode === 'xml' ? 'active' : ''} onClick={() => setViewMode('xml')}><Code2 size={14} />XML</button>
</div>
<div className="workspace-toolbar-actions">
<button className="tool-button" onClick={autoLayout} title="自动布局 (Ctrl+L)"><AlignVerticalSpaceAround size={15} />自动布局</button>
<button className="tool-button" onClick={autoLayout} title={`${t('toolbar.autoLayout')} (Ctrl+L)`}><AlignVerticalSpaceAround size={15} />{t('toolbar.autoLayout')}</button>
</div>
</div>
@@ -524,7 +555,20 @@ function Editor() {
onNodesChange={onNodesChange}
onConnect={connectNodes}
onNodeClick={(_event, node) => { setSelectedNodeId(node.id); setSelectedEdgeId(null) }}
onNodeDoubleClick={(_event, node) => { setModalNodeId(node.id); setSelectedNodeId(node.id) }}
onNodeDoubleClick={(_event, node) => {
if (node.data.nodeType === 'SubTree') {
const targetId = node.data.registrationName
commit((current) => {
if (!current.trees?.some((t) => t.id === targetId)) {
return { ...current, trees: [...(current.trees || []), { id: targetId, nodes: [], edges: [] }] }
}
return current
})
if (targetId) switchTree(targetId)
return
}
setModalNodeId(node.id); setSelectedNodeId(node.id)
}}
onEdgeClick={(_event, edge) => { setSelectedEdgeId(edge.id); setSelectedNodeId(null) }}
onPaneClick={() => { setSelectedNodeId(null); setSelectedEdgeId(null); setTreeMenuOpen(false); setSettingsOpen(false) }}
onNodeDragStart={() => { dragStartRef.current = treeDocument }}
@@ -568,10 +612,10 @@ function Editor() {
<div className="xml-view">
<div className="xml-header">
<div><FileCode2 size={16} /><span>{treeDocument.mainTreeId}.xml</span></div>
<button className="icon-button compact" onClick={copyXml} disabled={!xmlPreview.value} title="复制 XML"><Copy size={15} /></button>
<button className="icon-button compact" onClick={copyXml} disabled={!xmlPreview.value} title={t('xml.copyButton')}><Copy size={15} /></button>
</div>
{xmlPreview.error ? (
<div className="xml-error"><Activity size={20} /><strong>无法生成 XML</strong><span>{xmlPreview.error}</span></div>
<div className="xml-error"><Activity size={20} /><strong>{t('xml.errorHeading')}</strong><span>{xmlPreview.error}</span></div>
) : (
<pre><code>{xmlPreview.value}</code></pre>
)}
@@ -594,12 +638,13 @@ function Editor() {
onUpdateNode={updateNode}
onDeleteNode={deleteNode}
onSelectIssue={selectAndRevealNode}
onNavigateToTree={navigateToTree}
/>
</main>
<footer className="status-bar">
<div><span className={errors ? 'status-error' : 'status-valid'}>{errors ? `${errors} 个结构错误` : <><Check size={12} />结构有效</>}</span></div>
<div><span>{treeGraphs.length} 棵树</span><span>{treeDocument.nodes.length} 节点</span><span>{treeDocument.edges.length} 连接</span><span>{zoom}%</span><span>{window.arborflow ? `Desktop · ${window.arborflow.platform}` : 'Web Preview'}</span></div>
<div><span className={errors ? 'status-error' : 'status-valid'}>{errors ? `${errors} ${t('status.errors')}` : <><Check size={12} />{t('status.valid')}</>}</span></div>
<div><span>{treeGraphs.length} {t('status.trees')}</span><span>{treeDocument.nodes.length} {t('status.nodes')}</span><span>{treeDocument.edges.length} {t('status.edges')}</span><span>{zoom}%</span><span>{window.arborflow ? `Desktop · ${window.arborflow.platform}` : t('status.webPreview')}</span></div>
</footer>
{toast && <div className={`toast ${toast.kind}`}>{toast.message}</div>}

View File

@@ -35,10 +35,7 @@ export function BehaviorNodeCard({ data, selected }: NodeProps<BehaviorNode>) {
<div className={`behavior-node category-${data.category} runtime-${status.toLowerCase()} ${selected ? 'is-selected' : ''}`}>
{/* Top handle — skip for Root node since nothing connects above it */}
{!isRoot && (
<>
<Handle className="node-handle node-handle-target" type="target" position={Position.Top} />
<span className="handle-label handle-label-in">IN</span>
</>
)}
<div className="node-accent" />
@@ -87,12 +84,9 @@ export function BehaviorNodeCard({ data, selected }: NodeProps<BehaviorNode>) {
{/* Root badge */}
{isRoot && <span className="runtime-badge" style={{ color: 'var(--amber)', borderColor: 'var(--amber)', bottom: 'auto', top: 6, right: 7 }}>ROOT</span>}
{/* Bottom handle with OUT label — only if node can have children */}
{/* Bottom handle — only if node can have children */}
{canHaveChildren(data.nodeType) && (
<>
<Handle className="node-handle node-handle-source" type="source" position={Position.Bottom} />
<span className="handle-label handle-label-out">OUT</span>
</>
)}
</div>
)

View File

@@ -1,4 +1,4 @@
import { AlertCircle, Braces, Plus, Trash2, X } from 'lucide-react'
import { AlertCircle, Braces, ExternalLink, Plus, Trash2, X } from 'lucide-react'
import { useI18n } from '../context/I18nContext'
import type { BehaviorNode, BehaviorTreeDocument, PortDirection, ValidationIssue } from '../types'
@@ -10,9 +10,10 @@ interface InspectorProps {
onUpdateNode: (nodeId: string, data: Partial<BehaviorNode['data']>) => void
onDeleteNode: (nodeId: string) => void
onSelectIssue: (nodeId: string) => void
onNavigateToTree: (treeId: string) => void
}
export function Inspector({ document, selectedNode, issues, onUpdateDocument, onUpdateNode, onDeleteNode, onSelectIssue }: InspectorProps) {
export function Inspector({ document, selectedNode, issues, onUpdateDocument, onUpdateNode, onDeleteNode, onSelectIssue, onNavigateToTree }: InspectorProps) {
const { t } = useI18n()
const updatePort = (oldKey: string, key: string, value: string) => {
@@ -94,6 +95,11 @@ export function Inspector({ document, selectedNode, issues, onUpdateDocument, on
<div className="read-only-value mono" title={selectedNode.id}>{selectedNode.id}</div>
</div>
</div>
{selectedNode.data.nodeType === 'SubTree' && selectedNode.data.registrationName && (
<button className="secondary-button" style={{ width: '100%', marginBottom: 10 }} onClick={() => onNavigateToTree(selectedNode.data.registrationName)}>
<ExternalLink size={14} /> {t('inspector.enterSubtree')}
</button>
)}
<div className="section-divider" />
<div className="section-title-row">
<div className="section-title"><Braces size={15} />{t('inspector.ports')}</div>

View File

@@ -1,6 +1,7 @@
import { Activity, CircleStop, PlugZap, RotateCw, Trash2, X } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import { matchRuntimeNodes, parseMonitorMessage } from '../lib/runtime'
import { useI18n } from '../context/I18nContext'
import type { BehaviorNode, RuntimeEvent, RuntimeStatus } from '../types'
type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'error'
@@ -13,11 +14,11 @@ interface MonitorPanelProps {
onClearStatuses: () => void
}
const STATE_LABEL: Record<ConnectionState, string> = {
disconnected: '未连接', connecting: '连接中', connected: '已连接', error: '连接错误',
}
export function MonitorPanel({ open, nodes, onClose, onRuntimeUpdate, onClearStatuses }: MonitorPanelProps) {
const { t, lang } = useI18n()
const STATE_LABEL: Record<ConnectionState, string> = {
disconnected: t('monitor.disconnected'), connecting: t('monitor.connecting'), connected: t('monitor.connected'), error: t('monitor.error'),
}
const [endpoint, setEndpoint] = useState(() => localStorage.getItem('arborflow.monitor.endpoint') || 'ws://127.0.0.1:1667')
const [rosTopic, setRosTopic] = useState(() => localStorage.getItem('arborflow.monitor.topic') || '/arborflow/status')
const [connectionState, setConnectionState] = useState<ConnectionState>('disconnected')
@@ -120,20 +121,20 @@ export function MonitorPanel({ open, nodes, onClose, onRuntimeUpdate, onClearSta
<div className="monitor-header">
<div className="monitor-title"><Activity size={17} /><strong>Real-time Monitor</strong><span className={`connection-pill ${connectionState}`}><i />{STATE_LABEL[connectionState]}</span></div>
<div className="monitor-actions">
<button className="icon-button compact" onClick={() => { setEvents([]); onClearStatuses() }} title="清空运行状态"><Trash2 size={15} /></button>
<button className="icon-button compact" onClick={onClose} title="关闭监视器"><X size={16} /></button>
<button className="icon-button compact" onClick={() => { setEvents([]); onClearStatuses() }} title={t('monitor.clearRuntime')}><Trash2 size={15} /></button>
<button className="icon-button compact" onClick={onClose} title={t('monitor.closeMonitor')}><X size={16} /></button>
</div>
</div>
<div className="monitor-body">
<div className="monitor-connect">
<div className="field-group">
<label htmlFor="monitor-endpoint">ROS WebSocket 地址</label>
<label htmlFor="monitor-endpoint">{t('monitor.endpoint')}</label>
<div className="endpoint-row">
<input id="monitor-endpoint" className="mono" value={endpoint} onChange={(event) => setEndpoint(event.target.value)} disabled={shouldConnect} />
{shouldConnect ? (
<button className="secondary-button" onClick={disconnect}><CircleStop size={15} />断开</button>
<button className="secondary-button" onClick={disconnect}><CircleStop size={15} />{t('monitor.disconnect')}</button>
) : (
<button className="primary-button" onClick={() => setShouldConnect(true)}><PlugZap size={15} />连接</button>
<button className="primary-button" onClick={() => setShouldConnect(true)}><PlugZap size={15} />{t('monitor.connect')}</button>
)}
</div>
</div>
@@ -142,29 +143,29 @@ export function MonitorPanel({ open, nodes, onClose, onRuntimeUpdate, onClearSta
<input id="monitor-topic" className="mono" value={rosTopic} onChange={(event) => setRosTopic(event.target.value)} disabled={shouldConnect} />
</div>
<label className="toggle-row compact-toggle">
<span><span className="toggle-title">自动重连</span></span>
<span><span className="toggle-title">{t('monitor.autoReconnect')}</span></span>
<input type="checkbox" checked={autoReconnect} onChange={(event) => setAutoReconnect(event.target.checked)} />
</label>
<div className="monitor-metrics">
<div><span>消息</span><strong>{messageCount}</strong></div>
<div><span>延迟</span><strong>{lastLatency === null ? '--' : `${lastLatency} ms`}</strong></div>
<div><span>匹配</span><strong>{events.filter((event) => event.matchedNodeIds.length).length}</strong></div>
<div><span>{t('monitor.messages')}</span><strong>{messageCount}</strong></div>
<div><span>{t('monitor.latency')}</span><strong>{lastLatency === null ? '--' : `${lastLatency} ms`}</strong></div>
<div><span>{t('monitor.matched')}</span><strong>{events.filter((event) => event.matchedNodeIds.length).length}</strong></div>
</div>
</div>
<div className="runtime-legend">
{(['RUNNING', 'SUCCESS', 'FAILURE', 'IDLE', 'SKIPPED'] as RuntimeStatus[]).map((status) => <span key={status}><i className={`runtime-${status.toLowerCase()}`} />{status}</span>)}
</div>
<div className="event-stream">
<div className="event-stream-heading"><span>事件流</span>{connectionState === 'connecting' && <RotateCw className="spin" size={14} />}</div>
<div className="event-stream-heading"><span>{t('monitor.eventStream')}</span>{connectionState === 'connecting' && <RotateCw className="spin" size={14} />}</div>
<div className="event-list">
{!events.length && <div className="event-empty">等待节点状态</div>}
{!events.length && <div className="event-empty">{t('monitor.waiting')}</div>}
{events.map((event) => (
<div className="event-row" key={event.id}>
<time>{new Date(event.at).toLocaleTimeString('zh-CN', { hour12: false })}</time>
<time>{new Date(event.at).toLocaleTimeString(lang === 'zh' ? 'zh-CN' : 'en-US', { hour12: false })}</time>
<i className={`runtime-${event.status.toLowerCase()}`} />
<span className="event-node" title={event.nodeRef}>{event.nodeRef}</span>
<span className={`event-status runtime-${event.status.toLowerCase()}`}>{event.status}</span>
{!event.matchedNodeIds.length && <span className="unmatched">未匹配</span>}
{!event.matchedNodeIds.length && <span className="unmatched">{t('monitor.unmatched')}</span>}
</div>
))}
</div>

View File

@@ -12,7 +12,7 @@ interface NodeDetailModalProps {
}
export function NodeDetailModal({ node, onSave, onDelete, onClose }: NodeDetailModalProps) {
const { t } = useI18n()
const { t, lang } = useI18n()
const definition = NODE_DEFINITION_MAP.get(node.data.nodeType)
// Local working copy
@@ -100,7 +100,7 @@ export function NodeDetailModal({ node, onSave, onDelete, onClose }: NodeDetailM
<div className="modal-header">
<h2>
<span style={{ color: 'var(--category-color)' }}>
{definition?.labelZh && definition.labelZh !== definition.label ? definition.labelZh : definition?.label}
{lang === 'zh' && definition?.labelZh ? definition.labelZh : definition?.label}
</span>
{t('modal.editNode')}
</h2>

View File

@@ -93,7 +93,7 @@ export function NodePalette({ customModels, onAddNode, onAddCustomNode, onAddCus
<aside className="left-panel">
<div className="panel-heading">
<div>
<span className="eyebrow">LIBRARY</span>
<span className="eyebrow">{t('palette.eyebrow')}</span>
<h2>{t('palette.title')}</h2>
</div>
<span className="count-label">{NODE_DEFINITIONS.length + customModels.length}</span>
@@ -101,7 +101,7 @@ export function NodePalette({ customModels, onAddNode, onAddCustomNode, onAddCus
<div className="palette-search">
<Search size={16} />
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder={t('palette.searchPlaceholder')} aria-label={t('palette.searchPlaceholder')} />
{query && <button className="bare-icon" onClick={() => setQuery('')} title="清除搜索"><X size={15} /></button>}
{query && <button className="bare-icon" onClick={() => setQuery('')} title={t('palette.clearSearch')}><X size={15} /></button>}
</div>
<div className="palette-scroll">
{visibleModels.length > 0 && (
@@ -117,7 +117,7 @@ export function NodePalette({ customModels, onAddNode, onAddCustomNode, onAddCus
key={model.id}
onDragStart={(event) => beginCustomDrag(event, model.id)}
onClick={() => onAddCustomNode(model.id)}
title={`${catLabel(model.category)} · ${model.ports.length} 个端口`}
title={`${catLabel(model.category)} · ${t('palette.portCount').replace('{count}', String(model.ports.length))}`}
>
<span className="palette-item-icon"><Icon size={16} /></span>
<span className="palette-item-name">{model.id}</span>
@@ -170,7 +170,7 @@ export function NodePalette({ customModels, onAddNode, onAddCustomNode, onAddCus
key={item.type}
onDragStart={(event) => beginDrag(event, item.type)}
onClick={() => onAddNode(item.type)}
title={nodeDesc(item)}
title={`${nodeLabel(item)} — ${nodeDesc(item)}`}
>
<span className="palette-item-icon"><Icon size={16} /></span>
<span className="palette-item-name">{nodeLabel(item)}</span>

View File

@@ -6,6 +6,7 @@ const en: Record<string, string> = {
'toolbar.canvas': 'Canvas',
'toolbar.xml': 'XML',
'toolbar.autoLayout': 'Auto Layout',
'toolbar.viewSwitch': 'View Switch',
'toolbar.monitor': 'Monitor',
/* ── Header Actions ── */
@@ -16,6 +17,7 @@ const en: Record<string, string> = {
'action.redo': 'Redo',
'action.importXml': 'Import XML',
'action.exportXml': 'Export XML',
'action.unsaved': 'Unsaved',
'action.settings': 'Settings',
/* ── Settings ── */
@@ -35,7 +37,10 @@ const en: Record<string, string> = {
'palette.title': 'Nodes',
'palette.searchPlaceholder': 'Search nodes',
'palette.customNodes': 'XML Custom Nodes',
'palette.eyebrow': 'LIBRARY',
'palette.noMatch': 'No matching nodes',
'palette.clearSearch': 'Clear search',
'palette.portCount': '{count} port(s)',
'palette.customModelAdd': 'Add Custom Node',
'palette.customModelName': 'Node Name / ID',
'palette.customModelType': 'Base Type',
@@ -101,6 +106,7 @@ const en: Record<string, string> = {
'inspector.portDirection': 'Direction',
'inspector.breakpoint': 'Breakpoint',
'inspector.breakpointDesc': 'Flag when execution reaches this node',
'inspector.enterSubtree': 'Enter Subtree',
'inspector.notes': 'Notes',
'inspector.deleteNode': 'Delete Node',
'inspector.structureCheck': 'Structure Check',
@@ -158,6 +164,7 @@ const en: Record<string, string> = {
/* ── XML View ── */
'xml.copyButton': 'Copy XML',
'xml.errorHeading': 'Cannot Generate XML',
/* ── Monitor ── */
'monitor.disconnected': 'Disconnected',
@@ -174,6 +181,8 @@ const en: Record<string, string> = {
'monitor.connect': 'Connect',
'monitor.messages': 'Messages',
'monitor.latency': 'Latency',
'monitor.clearRuntime': 'Clear Runtime Status',
'monitor.closeMonitor': 'Close Monitor',
'monitor.matched': 'Matched',
}

View File

@@ -6,6 +6,7 @@ const zh: Record<string, string> = {
'toolbar.canvas': '画布',
'toolbar.xml': 'XML',
'toolbar.autoLayout': '自动布局',
'toolbar.viewSwitch': '视图切换',
'toolbar.monitor': 'Monitor',
/* ── Header Actions ── */
@@ -16,6 +17,7 @@ const zh: Record<string, string> = {
'action.redo': '重做',
'action.importXml': '导入 XML',
'action.exportXml': '导出 XML',
'action.unsaved': '未保存',
'action.settings': '设置',
/* ── Settings ── */
@@ -35,7 +37,10 @@ const zh: Record<string, string> = {
'palette.title': '节点',
'palette.searchPlaceholder': '搜索节点',
'palette.customNodes': 'XML 自定义节点',
'palette.eyebrow': '节点库',
'palette.noMatch': '没有匹配节点',
'palette.clearSearch': '清除搜索',
'palette.portCount': '{count} 个端口',
'palette.customModelAdd': '添加自定义节点',
'palette.customModelName': '节点名称 / ID',
'palette.customModelType': '基础类型',
@@ -101,6 +106,7 @@ const zh: Record<string, string> = {
'inspector.portDirection': '方向',
'inspector.breakpoint': '断点',
'inspector.breakpointDesc': '运行到此节点时标记',
'inspector.enterSubtree': '进入子树',
'inspector.notes': '备注',
'inspector.deleteNode': '删除节点',
'inspector.structureCheck': '结构检查',
@@ -158,6 +164,7 @@ const zh: Record<string, string> = {
/* ── XML View ── */
'xml.copyButton': '复制 XML',
'xml.errorHeading': '无法生成 XML',
/* ── Monitor ── */
'monitor.disconnected': '未连接',
@@ -174,6 +181,8 @@ const zh: Record<string, string> = {
'monitor.connect': '连接',
'monitor.messages': '消息',
'monitor.latency': '延迟',
'monitor.clearRuntime': '清空运行状态',
'monitor.closeMonitor': '关闭监视器',
'monitor.matched': '匹配',
}

View File

@@ -1,10 +1,33 @@
import { createNode } from './catalog'
import { syncActiveTree, switchDocumentTree } from './trees'
import type { BehaviorTreeDocument } from '../types'
import type { BehaviorEdge, BehaviorNode, BehaviorTreeDocument } from '../types'
export function serializeProject(document: BehaviorTreeDocument) {
return JSON.stringify(syncActiveTree(document), null, 2) + '\n'
}
/** Ensure the active tree has a Root node at the top. If not, add one. */
export function ensureRootNode(nodes: BehaviorNode[], edges: BehaviorEdge[]): { nodes: BehaviorNode[]; edges: BehaviorEdge[] } {
const hasRoot = nodes.some((n) => n.data.nodeType === 'Root')
if (hasRoot) return { nodes, edges }
const incoming = new Map<string, number>()
edges.forEach((e) => incoming.set(e.target, (incoming.get(e.target) || 0) + 1))
const roots = nodes.filter((n) => (incoming.get(n.id) || 0) === 0)
if (roots.length === 0) return { nodes, edges }
const rootNode = createNode('Root')
rootNode.data = { ...rootNode.data, label: 'Root', registrationName: 'Root' }
let edgeIndex = edges.length
const newEdges: BehaviorEdge[] = [...edges]
roots.forEach((r) => {
newEdges.push({ id: `edge_root_${edgeIndex++}`, source: rootNode.id, target: r.id, type: 'smoothstep' })
})
return { nodes: [rootNode, ...nodes], edges: newEdges }
}
export function parseProject(content: string): BehaviorTreeDocument {
const value: unknown = JSON.parse(content)
if (!value || typeof value !== 'object') throw new Error('工程文件内容无效')
@@ -16,6 +39,9 @@ export function parseProject(content: string): BehaviorTreeDocument {
throw new Error('工程文件缺少节点或连接数据')
}
const document = candidate as BehaviorTreeDocument
const { nodes, edges } = ensureRootNode(document.nodes, document.edges)
document.nodes = nodes
document.edges = edges
const requestedTree = document.activeTreeId || document.mainTreeId
return document.trees?.length ? switchDocumentTree(document, requestedTree) : document
}

View File

@@ -200,6 +200,26 @@ function parseBehaviorTree(element: Element, models: Map<string, CustomNodeModel
Array.from(nodeElement.children).forEach((child) => visit(child, node.id))
}
visit(treeRoot)
// Ensure a Root node sits at the top of every imported tree
const incoming = new Map(nodes.map((n) => [n.id, 0]))
edges.forEach((e) => incoming.set(e.target, (incoming.get(e.target) || 0) + 1))
const roots = nodes.filter((n) => (incoming.get(n.id) || 0) === 0)
const hasRootNode = roots.some((n) => n.data.nodeType === 'Root')
if (!hasRootNode && roots.length > 0) {
const rootNode = createNode('Root')
rootNode.data = {
...rootNode.data,
label: treeId,
registrationName: treeId,
}
nodes.push(rootNode)
roots.forEach((r) => {
edges.push({ id: `edge_${treeId}_${edgeIndex++}`, source: rootNode.id, target: r.id, type: 'smoothstep' })
})
}
return layoutGraph({ id: treeId, nodes, edges })
}

View File

@@ -356,6 +356,11 @@
--color-root: #d99a20;
}
/* ── Smooth theme transition ── */
*, *::before, *::after {
transition: background-color 0.35s ease, color 0.25s ease, border-color 0.35s ease, box-shadow 0.35s ease;
}
* { box-sizing: border-box; }
html, body, #root { width: 100%; height: 100%; margin: 0; overflow: hidden; }
body { background: var(--bg-primary); }
@@ -608,20 +613,24 @@ button:disabled { cursor: default; opacity: .38; }
.node-top-row {
display: flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
min-height: 60px;
gap: 8px;
padding: 0 14px;
}
.node-accent { align-self: stretch; flex: 0 0 4px; border-radius: 6px 0 0 6px; background: var(--category-color); }
.node-icon { display: grid; place-items: center; flex: 0 0 33px; height: 33px; margin-left: 10px; border: 1px solid color-mix(in srgb, var(--category-color) 35%, var(--border-icon-well)); border-radius: 6px; color: var(--category-color); background: var(--bg-icon-well); }
.node-copy { min-width: 0; margin: 0 11px 0 9px; flex: 1; }
.node-title { max-width: 135px; overflow: hidden; color: var(--text-title); font-size: 12px; font-weight: 650; text-overflow: ellipsis; white-space: nowrap; }
.node-subtitle { max-width: 135px; overflow: hidden; color: var(--text-dim); font-size: 9px; text-overflow: ellipsis; white-space: nowrap; margin-top: 1px; }
.node-type { max-width: 132px; margin-top: 6px; overflow: hidden; color: var(--text-mono); font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
.node-accent { position: absolute; left: 0; top: 0; bottom: 0; width: 4px; border-radius: 7px 0 0 7px; background: var(--category-color); }
.node-icon { display: grid; place-items: center; flex: 0 0 33px; height: 33px; border: 1px solid color-mix(in srgb, var(--category-color) 35%, var(--border-icon-well)); border-radius: 6px; color: var(--category-color); background: var(--bg-icon-well); }
.node-copy { min-width: 0; flex: 1; text-align: center; }
.node-title { max-width: 135px; overflow: hidden; color: var(--text-title); font-size: 12px; font-weight: 650; text-overflow: ellipsis; white-space: nowrap; margin: 0 auto; }
.node-subtitle { max-width: 135px; overflow: hidden; color: var(--text-dim); font-size: 9px; text-overflow: ellipsis; white-space: nowrap; margin: 1px auto 0; }
.node-type { max-width: 132px; margin: 6px auto 0; overflow: hidden; color: var(--text-mono); font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
/* ── Port params inside node card ── */
.node-ports-row {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: 4px 10px 5px 10px;
border-top: 1px solid var(--border-default);
@@ -665,11 +674,9 @@ button:disabled { cursor: default; opacity: .38; }
.node-ports-more {
color: var(--text-dim);
font-size: 8px;
padding-left: 2px;
margin-top: 1px;
}
/* ── Handle labels ── */
.handle-wrapper {
position: relative;
display: flex;
@@ -677,19 +684,6 @@ button:disabled { cursor: default; opacity: .38; }
justify-content: center;
height: 10px;
}
.handle-label {
position: absolute;
left: 50%;
transform: translateX(-50%);
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
font-size: 7px;
font-weight: 650;
color: var(--text-dim);
pointer-events: none;
}
.handle-label-in { top: 1px; }
.handle-label-out { bottom: 1px; }
.node-handle { width: 9px !important; height: 9px !important; border: 2px solid var(--handle-border) !important; background: var(--handle-bg) !important; }
.node-handle:hover { border-color: var(--category-color) !important; background: var(--category-color) !important; }
.node-handle-target { top: -5px !important; }