From 2bdd58108a8eafece25addac44d71432007f6b1a Mon Sep 17 00:00:00 2001 From: TuxMonkey Date: Tue, 4 Aug 2026 01:44:52 +0800 Subject: [PATCH] feat: dark/light theme, i18n (zh/en), inline node params with IN/OUT, root node, custom node creation, minimap & light mode contrast fixes --- index.html | 1 + src/App.tsx | 72 ++- src/components/BehaviorNodeCard.tsx | 86 ++- src/components/Inspector.tsx | 88 ++- src/components/NodeDetailModal.tsx | 202 +++++++ src/components/NodePalette.tsx | 124 +++- src/context/I18nContext.tsx | 52 ++ src/context/ThemeContext.tsx | 43 ++ src/i18n/en.ts | 180 ++++++ src/i18n/zh.ts | 180 ++++++ src/lib/catalog.ts | 42 +- src/lib/demo.ts | 25 +- src/lib/xml.ts | 17 +- src/main.tsx | 8 +- src/styles.css | 899 +++++++++++++++++++++++----- src/types.ts | 6 +- test_rmul_2025.xml | 144 +++++ 17 files changed, 1936 insertions(+), 233 deletions(-) create mode 100644 src/components/NodeDetailModal.tsx create mode 100644 src/context/I18nContext.tsx create mode 100644 src/context/ThemeContext.tsx create mode 100644 src/i18n/en.ts create mode 100644 src/i18n/zh.ts create mode 100644 test_rmul_2025.xml diff --git a/index.html b/index.html index d5f87e0..9906dda 100644 --- a/index.html +++ b/index.html @@ -5,6 +5,7 @@ + ArborFlow diff --git a/src/App.tsx b/src/App.tsx index 24e112f..02dd73e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -26,8 +26,11 @@ import { FolderOpen, GitBranch, MonitorDot, + Moon, Redo2, Save, + Settings, + Sun, TreePine, Undo2, Upload, @@ -36,6 +39,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { BehaviorNodeCard } from './components/BehaviorNodeCard' import { Inspector } from './components/Inspector' import { MonitorPanel } from './components/MonitorPanel' +import { NodeDetailModal } from './components/NodeDetailModal' import { NodePalette } from './components/NodePalette' import { createNode, createNodeFromModel, maxChildren } from './lib/catalog' import { createDemoDocument, createEmptyDocument } from './lib/demo' @@ -44,7 +48,9 @@ import { downloadText, parseProject, pickBrowserFile, serializeProject } from '. import { activeTreeId, switchDocumentTree, syncedTreeGraphs } from './lib/trees' import { validateDocument, wouldCreateCycle } from './lib/validation' import { documentToXml, xmlToDocument } from './lib/xml' -import type { BehaviorNode, BehaviorTreeDocument, NodeType, RuntimeStatus } from './types' +import { useTheme } from './context/ThemeContext' +import { useI18n } from './context/I18nContext' +import type { BehaviorNode, BehaviorTreeDocument, CustomNodeModel, NodeType, RuntimeStatus } from './types' const nodeTypes = { behavior: BehaviorNodeCard } const PROJECT_FILTERS = [{ name: 'ArborFlow Project', extensions: ['arborflow', 'json'] }] @@ -54,14 +60,18 @@ type ViewMode = 'canvas' | 'xml' type DocumentUpdater = BehaviorTreeDocument | ((current: BehaviorTreeDocument) => BehaviorTreeDocument) function Editor() { + const { theme, toggleTheme } = useTheme() + const { t, lang, setLang } = useI18n() const [treeDocument, setTreeDocument] = useState(() => createDemoDocument()) const [projectPath, setProjectPath] = useState(null) const [dirty, setDirty] = useState(false) const [viewMode, setViewMode] = useState('canvas') const [treeMenuOpen, setTreeMenuOpen] = useState(false) + const [settingsOpen, setSettingsOpen] = useState(false) const [selectedNodeId, setSelectedNodeId] = useState(null) const [selectedEdgeId, setSelectedEdgeId] = useState(null) const [monitorOpen, setMonitorOpen] = useState(false) + const [modalNodeId, setModalNodeId] = useState(null) const [runtimeStatuses, setRuntimeStatuses] = useState>({}) const [toast, setToast] = useState<{ kind: 'success' | 'error'; message: string } | null>(null) const [historyRevision, setHistoryRevision] = useState(0) @@ -163,6 +173,13 @@ function Editor() { setViewMode('canvas') }, [commit, screenToFlowPosition, treeDocument.nodeModels]) + const addCustomModel = useCallback((model: CustomNodeModel) => { + commit((current) => ({ + ...current, + nodeModels: [...(current.nodeModels || []), model], + })) + }, [commit]) + const switchTree = useCallback((treeId: string) => { if (treeId === currentTreeId) { setTreeMenuOpen(false) @@ -201,9 +218,7 @@ function Editor() { }, [commit]) const onNodesChange = useCallback((changes: NodeChange[]) => { - const positionChanges = changes.filter((change) => change.type === 'position') - if (!positionChanges.length) return - setTreeDocument((current) => ({ ...current, nodes: applyNodeChanges(positionChanges, current.nodes) })) + setTreeDocument((current) => ({ ...current, nodes: applyNodeChanges(changes, current.nodes) })) }, []) const connectNodes: OnConnect = useCallback((connection: Connection) => { @@ -445,10 +460,32 @@ function Editor() { + + + {settingsOpen && ( +
+
{t('settings.title')}
+
{t('settings.theme')}
+ +
{t('settings.language')}
+ + +
+ )} +
- +
{ event.preventDefault(); event.dataTransfer.dropEffect = 'copy' }} onDrop={handleDrop}> {viewMode === 'canvas' ? ( @@ -487,8 +524,9 @@ function Editor() { onNodesChange={onNodesChange} onConnect={connectNodes} onNodeClick={(_event, node) => { setSelectedNodeId(node.id); setSelectedEdgeId(null) }} + onNodeDoubleClick={(_event, node) => { setModalNodeId(node.id); setSelectedNodeId(node.id) }} onEdgeClick={(_event, edge) => { setSelectedEdgeId(edge.id); setSelectedNodeId(null) }} - onPaneClick={() => { setSelectedNodeId(null); setSelectedEdgeId(null); setTreeMenuOpen(false) }} + onPaneClick={() => { setSelectedNodeId(null); setSelectedEdgeId(null); setTreeMenuOpen(false); setSettingsOpen(false) }} onNodeDragStart={() => { dragStartRef.current = treeDocument }} onNodeDragStop={() => { if (dragStartRef.current) { @@ -510,7 +548,7 @@ function Editor() { defaultEdgeOptions={{ type: 'smoothstep' }} proOptions={{ hideAttribution: true }} > - + { const category = (node.data as BehaviorNode['data']).category - return { control: '#4f8cff', decorator: '#c084fc', action: '#22c98b', condition: '#f2b84b', subtree: '#ec6f66' }[category] + const colors = theme === 'dark' + ? { control: '#4f8cff', decorator: '#c084fc', action: '#22c98b', condition: '#f2b84b', subtree: '#ec6f66', root: '#eeb84a' } + : { control: '#3a72e0', decorator: '#8b5fd4', action: '#1aad6f', condition: '#d99a20', subtree: '#d9605a', root: '#d99a20' } + return colors[category] || (theme === 'dark' ? '#68717b' : '#929aa3') }} - maskColor="rgba(10, 12, 15, 0.72)" + maskColor={theme === 'dark' ? 'rgba(10, 12, 15, 0.72)' : 'rgba(200, 205, 212, 0.45)'} /> ) : ( @@ -561,6 +602,19 @@ function Editor() {
{treeGraphs.length} 棵树{treeDocument.nodes.length} 节点{treeDocument.edges.length} 连接{zoom}%{window.arborflow ? `Desktop · ${window.arborflow.platform}` : 'Web Preview'}
{toast &&
{toast.message}
} + + {modalNodeId && (() => { + const modalNode = treeDocument.nodes.find((n) => n.id === modalNodeId) + if (!modalNode) return null + return ( + { deleteNode(nodeId); setModalNodeId(null) }} + onClose={() => setModalNodeId(null)} + /> + ) + })()}
) } diff --git a/src/components/BehaviorNodeCard.tsx b/src/components/BehaviorNodeCard.tsx index abd3435..9facdbd 100644 --- a/src/components/BehaviorNodeCard.tsx +++ b/src/components/BehaviorNodeCard.tsx @@ -1,26 +1,98 @@ import { Handle, Position, type NodeProps } from '@xyflow/react' import { CircleDot, OctagonAlert } from 'lucide-react' import { NODE_DEFINITION_MAP, canHaveChildren } from '../lib/catalog' -import type { BehaviorNode } from '../types' +import { useI18n } from '../context/I18nContext' +import type { BehaviorNode, PortDirection } from '../types' + +const MAX_INLINE_PORTS = 5 + +function getPortDirection( + portName: string, + portDirections: Record | undefined, +): PortDirection | null { + if (!portDirections) return null + return portDirections[portName] || null +} export function BehaviorNodeCard({ data, selected }: NodeProps) { const definition = NODE_DEFINITION_MAP.get(data.nodeType) const Icon = definition?.icon || CircleDot const status = data.runtimeStatus || 'IDLE' + const { lang } = useI18n() + + // Bilingual display: show Chinese as primary when zh, English as secondary + const isZh = lang === 'zh' + const primaryLabel = isZh ? (definition?.labelZh || data.label) : data.label + const secondaryLabel = isZh ? data.label : (definition?.labelZh || '') + + // Port entries for inline display + const portEntries = Object.entries(data.ports || {}) + const visiblePorts = portEntries.slice(0, MAX_INLINE_PORTS) + const hiddenCount = portEntries.length - MAX_INLINE_PORTS + const isRoot = data.nodeType === 'Root' return (
- + {/* Top handle — skip for Root node since nothing connects above it */} + {!isRoot && ( + <> + + IN + + )} +
-
-
-
{data.label}
-
{data.registrationName}
+ +
+
+
+
{primaryLabel}
+ {secondaryLabel && secondaryLabel !== primaryLabel && ( +
{secondaryLabel}
+ )} + {!secondaryLabel || secondaryLabel === primaryLabel ? ( +
{data.registrationName}
+ ) : ( +
{data.registrationName}
+ )} +
+ + {/* Inline port parameters */} + {portEntries.length > 0 && ( +
+ {visiblePorts.map(([key, value]) => { + const dir = getPortDirection(key, data.portDirections) + return ( +
+ {dir && ( + + {dir === 'input' ? 'IN' : 'OUT'} + + )} + {key} + {value && : {value}} +
+ ) + })} + {hiddenCount > 0 && ( +
...+{hiddenCount} more
+ )} +
+ )} + {data.breakpoint && } {status !== 'IDLE' && {status}} + + {/* Root badge */} + {isRoot && ROOT} + + {/* Bottom handle with OUT label — only if node can have children */} {canHaveChildren(data.nodeType) && ( - + <> + + OUT + )}
) diff --git a/src/components/Inspector.tsx b/src/components/Inspector.tsx index 3179ea9..32a30bc 100644 --- a/src/components/Inspector.tsx +++ b/src/components/Inspector.tsx @@ -1,6 +1,6 @@ import { AlertCircle, Braces, Plus, Trash2, X } from 'lucide-react' -import { CATEGORY_LABELS } from '../lib/catalog' -import type { BehaviorNode, BehaviorTreeDocument, ValidationIssue } from '../types' +import { useI18n } from '../context/I18nContext' +import type { BehaviorNode, BehaviorTreeDocument, PortDirection, ValidationIssue } from '../types' interface InspectorProps { document: BehaviorTreeDocument @@ -13,6 +13,8 @@ interface InspectorProps { } export function Inspector({ document, selectedNode, issues, onUpdateDocument, onUpdateNode, onDeleteNode, onSelectIssue }: InspectorProps) { + const { t } = useI18n() + const updatePort = (oldKey: string, key: string, value: string) => { if (!selectedNode) return const ports = { ...selectedNode.data.ports } @@ -21,11 +23,27 @@ export function Inspector({ document, selectedNode, issues, onUpdateDocument, on onUpdateNode(selectedNode.id, { ports }) } + const updatePortDirection = (key: string, direction: PortDirection | '') => { + if (!selectedNode) return + const portDirections = { ...(selectedNode.data.portDirections || {}) } + if (direction) { + portDirections[key] = direction + } else { + delete portDirections[key] + } + onUpdateNode(selectedNode.id, { portDirections: Object.keys(portDirections).length > 0 ? portDirections : undefined }) + } + const removePort = (key: string) => { if (!selectedNode) return const ports = { ...selectedNode.data.ports } delete ports[key] - onUpdateNode(selectedNode.id, { ports }) + const portDirections = selectedNode.data.portDirections ? { ...selectedNode.data.portDirections } : {} + delete portDirections[key] + onUpdateNode(selectedNode.id, { + ports, + portDirections: Object.keys(portDirections).length > 0 ? portDirections : undefined, + }) } const addPort = () => { @@ -36,88 +54,106 @@ export function Inspector({ document, selectedNode, issues, onUpdateDocument, on onUpdateNode(selectedNode.id, { ports: { ...selectedNode.data.ports, [key]: '' } }) } + const getPortDir = (key: string): PortDirection | '' => { + if (!selectedNode?.data.portDirections) return '' + return selectedNode.data.portDirections[key] || '' + } + return (