import { useEffect, useCallback, useRef } from 'preact/hooks'; import { useSignal, useComputed } from '@preact/signals'; import { treeData, expandedUuids, selectedNode, searchQuery, updateTick, type TreeNode, } from '../store'; import { isReady, getScene, resolveNodeByPath } from '../engine'; // ── 构建树数据 ───────────────────────────────────────────── function buildTree(children: any[], path: string[]): TreeNode[] { const result: TreeNode[] = []; for (const ccNode of children) { const childPath = [...path, ccNode.uuid]; const node: TreeNode = { uuid: ccNode.uuid, name: ccNode.name, active: ccNode.activeInHierarchy, children: ccNode.children?.length > 0 ? buildTree(ccNode.children, childPath) : [], path: childPath, }; result.push(node); } return result; } // ── 搜索:收集所有匹配节点(扁平列表)───────────────────── interface FlatMatch { node: TreeNode; depth: number; } function collectMatches(nodes: TreeNode[], query: string, depth = 0): FlatMatch[] { const q = query.toLowerCase(); const result: FlatMatch[] = []; for (const node of nodes) { if (node.name.toLowerCase().includes(q)) { result.push({ node, depth }); } result.push(...collectMatches(node.children, query, depth + 1)); } return result; } // ── 搜索时自动展开匹配节点的所有祖先 ───────────────────── function expandAncestors(nodes: TreeNode[], query: string): Set { const q = query.toLowerCase(); const toExpand = new Set(); function walk(node: TreeNode, ancestors: string[]): boolean { const matched = node.name.toLowerCase().includes(q); let childMatched = false; for (const child of node.children) { if (walk(child, [...ancestors, node.uuid])) childMatched = true; } if (matched || childMatched) { for (const uuid of ancestors) toExpand.add(uuid); } return matched || childMatched; } for (const node of nodes) walk(node, []); return toExpand; } // ── 高亮关键词 ──────────────────────────────────────────── function HighlightText({ text, query }: { text: string; query: string }) { if (!query) return {text}; const idx = text.toLowerCase().indexOf(query.toLowerCase()); if (idx === -1) return {text}; return ( {text.slice(0, idx)} {text.slice(idx, idx + query.length)} {text.slice(idx + query.length)} ); } // ── 搜索结果行 ──────────────────────────────────────────── function SearchResultItem({ node, query }: { node: TreeNode; query: string }) { const isSelected = useComputed(() => selectedNode.value?.uuid === node.uuid); const handleClick = useCallback(() => { const ccNode = resolveNodeByPath(node.path); selectedNode.value = ccNode ?? null; }, [node.path]); return (
); } // ── 单个树节点组件 ───────────────────────────────────────── interface TreeNodeItemProps { node: TreeNode; depth: number; } function TreeNodeItem({ node, depth }: TreeNodeItemProps) { const expanded = useComputed(() => expandedUuids.value.has(node.uuid)); const isSelected = useComputed(() => selectedNode.value?.uuid === node.uuid); const hasChildren = node.children.length > 0; const handleClick = useCallback( (e: MouseEvent) => { e.stopPropagation(); const ccNode = resolveNodeByPath(node.path); selectedNode.value = ccNode ?? null; }, [node.path], ); const handleToggle = useCallback( (e: MouseEvent) => { e.stopPropagation(); const next = new Set(expandedUuids.value); if (next.has(node.uuid)) { next.delete(node.uuid); } else { next.add(node.uuid); } expandedUuids.value = next; }, [node.uuid], ); return (
{node.name}
{hasChildren && expanded.value && (
{node.children.map((child) => ( ))}
)}
); } // ── TreePanel 主组件 ─────────────────────────────────────── export function TreePanel() { const initialized = useSignal(false); const inputRef = useRef(null); const query = useComputed(() => searchQuery.value.trim()); // 搜索结果(扁平列表) const searchResults = useComputed(() => { if (!query.value) return null; return collectMatches(treeData.value, query.value); }); // 搜索关键词变化时,自动展开匹配节点的祖先 useEffect(() => { if (!query.value) return; const ancestors = expandAncestors(treeData.value, query.value); if (ancestors.size === 0) return; expandedUuids.value = new Set([...expandedUuids.value, ...ancestors]); }, [query.value]); useEffect(() => { let rafId: number; let started = false; function refreshTree() { if (isReady()) { if (!started) { started = true; initialized.value = true; } treeData.value = buildTree(getScene().children, []); updateTick.value = -updateTick.value; } rafId = requestAnimationFrame(refreshTree); } const pollId = setInterval(() => { if (isReady()) { clearInterval(pollId); rafId = requestAnimationFrame(refreshTree); } }, 500); return () => { clearInterval(pollId); cancelAnimationFrame(rafId); }; }, []); const handleSearchInput = useCallback((e: Event) => { searchQuery.value = (e.target as HTMLInputElement).value; }, []); const handleClear = useCallback(() => { searchQuery.value = ''; inputRef.current?.focus(); }, []); // Esc 清空搜索 const handleKeyDown = useCallback((e: KeyboardEvent) => { if (e.key === 'Escape') { searchQuery.value = ''; } }, []); return (
节点树
{/* 搜索栏 */}
{!initialized.value ? (
等待引擎初始化…
) : searchResults.value !== null ? ( // 搜索模式:扁平结果列表 searchResults.value.length === 0 ? (
未找到匹配节点
) : ( <>
{searchResults.value.length} 个结果
{searchResults.value.map(({ node }) => ( ))} ) ) : treeData.value.length === 0 ? (
场景为空
) : ( // 正常树模式 treeData.value.map((node) => ) )}
); }