mirror of
https://github.com/potato47/ccc-devtools.git
synced 2026-04-06 05:12:28 +00:00
276 lines
8.6 KiB
TypeScript
276 lines
8.6 KiB
TypeScript
|
|
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<string> {
|
|||
|
|
const q = query.toLowerCase();
|
|||
|
|
const toExpand = new Set<string>();
|
|||
|
|
|
|||
|
|
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 <span class="tree-label">{text}</span>;
|
|||
|
|
const idx = text.toLowerCase().indexOf(query.toLowerCase());
|
|||
|
|
if (idx === -1) return <span class="tree-label">{text}</span>;
|
|||
|
|
return (
|
|||
|
|
<span class="tree-label">
|
|||
|
|
{text.slice(0, idx)}
|
|||
|
|
<mark class="tree-highlight">{text.slice(idx, idx + query.length)}</mark>
|
|||
|
|
{text.slice(idx + query.length)}
|
|||
|
|
</span>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── 搜索结果行 ────────────────────────────────────────────
|
|||
|
|
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 (
|
|||
|
|
<div
|
|||
|
|
class={`tree-row${isSelected.value ? ' selected' : ''}${!node.active ? ' inactive' : ''}`}
|
|||
|
|
style={{ paddingLeft: '8px' }}
|
|||
|
|
onClick={handleClick}
|
|||
|
|
>
|
|||
|
|
<HighlightText text={node.name} query={query} />
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── 单个树节点组件 ─────────────────────────────────────────
|
|||
|
|
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 (
|
|||
|
|
<div class="tree-node">
|
|||
|
|
<div
|
|||
|
|
class={`tree-row${isSelected.value ? ' selected' : ''}${!node.active ? ' inactive' : ''}`}
|
|||
|
|
style={{ paddingLeft: `${depth * 14 + 6}px` }}
|
|||
|
|
onClick={handleClick}
|
|||
|
|
>
|
|||
|
|
<span
|
|||
|
|
class={`tree-arrow${hasChildren ? '' : ' invisible'}${expanded.value ? ' expanded' : ''}`}
|
|||
|
|
onClick={handleToggle}
|
|||
|
|
>
|
|||
|
|
›
|
|||
|
|
</span>
|
|||
|
|
<span class="tree-label">{node.name}</span>
|
|||
|
|
</div>
|
|||
|
|
{hasChildren && expanded.value && (
|
|||
|
|
<div class="tree-children">
|
|||
|
|
{node.children.map((child) => (
|
|||
|
|
<TreeNodeItem key={child.uuid} node={child} depth={depth + 1} />
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── TreePanel 主组件 ───────────────────────────────────────
|
|||
|
|
export function TreePanel() {
|
|||
|
|
const initialized = useSignal(false);
|
|||
|
|
const inputRef = useRef<HTMLInputElement>(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 (
|
|||
|
|
<div class="tree-panel">
|
|||
|
|
<div class="panel-header">节点树</div>
|
|||
|
|
|
|||
|
|
{/* 搜索栏 */}
|
|||
|
|
<div class="tree-search-bar">
|
|||
|
|
<span class="tree-search-icon">⌕</span>
|
|||
|
|
<input
|
|||
|
|
ref={inputRef}
|
|||
|
|
class="tree-search-input"
|
|||
|
|
type="text"
|
|||
|
|
placeholder="搜索节点…"
|
|||
|
|
value={searchQuery.value}
|
|||
|
|
onInput={handleSearchInput}
|
|||
|
|
onKeyDown={handleKeyDown}
|
|||
|
|
/>
|
|||
|
|
{query.value && (
|
|||
|
|
<button class="tree-search-clear" onClick={handleClear} title="清空">
|
|||
|
|
✕
|
|||
|
|
</button>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="tree-scroll">
|
|||
|
|
{!initialized.value ? (
|
|||
|
|
<div class="tree-empty">等待引擎初始化…</div>
|
|||
|
|
) : searchResults.value !== null ? (
|
|||
|
|
// 搜索模式:扁平结果列表
|
|||
|
|
searchResults.value.length === 0 ? (
|
|||
|
|
<div class="tree-empty">未找到匹配节点</div>
|
|||
|
|
) : (
|
|||
|
|
<>
|
|||
|
|
<div class="tree-search-count">{searchResults.value.length} 个结果</div>
|
|||
|
|
{searchResults.value.map(({ node }) => (
|
|||
|
|
<SearchResultItem key={node.uuid} node={node} query={query.value} />
|
|||
|
|
))}
|
|||
|
|
</>
|
|||
|
|
)
|
|||
|
|
) : treeData.value.length === 0 ? (
|
|||
|
|
<div class="tree-empty">场景为空</div>
|
|||
|
|
) : (
|
|||
|
|
// 正常树模式
|
|||
|
|
treeData.value.map((node) => <TreeNodeItem key={node.uuid} node={node} depth={0} />)
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|