feat(web): add type filter bar to object tree

Chips highlight object types to show only those in the tree; with none highlighted, everything is shown. Filtered nodes keep their original index path so selection stays stable.
This commit is contained in:
2026-08-14 10:29:01 +08:00
parent ee7e73c798
commit 2403abc07b
+119 -13
View File
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useMemo, useState } from 'react';
import { Icon } from '@iconify/react';
import type { ObjectTreeNode } from '@tts/extract';
import { iconsForObject } from './objectIcons';
@@ -10,19 +10,78 @@ interface Props {
}
export default function ObjectTree({ nodes, selectedPath, onSelect }: Props) {
const [highlighted, setHighlighted] = useState<Set<string>>(() => new Set());
const types = useMemo(() => collectTypes(nodes), [nodes]);
// When nothing is highlighted, every type is shown. Otherwise only the
// highlighted types are visible.
const visibleNodes = useMemo(
() => filterTree(nodes, highlighted),
[nodes, highlighted],
);
const toggleType = (name: string) =>
setHighlighted((prev) => {
const next = new Set(prev);
if (next.has(name)) next.delete(name);
else next.add(name);
return next;
});
const clearAll = () => setHighlighted(new Set());
return (
<ul className="space-y-0.5">
{nodes.map((node, index) => (
<TreeNode
key={index}
node={node}
depth={0}
path={`${index}`}
selectedPath={selectedPath}
onSelect={onSelect}
/>
))}
</ul>
<div className="space-y-2">
{types.length > 0 && (
<div className="flex flex-wrap gap-1 border-b border-zinc-800 pb-2">
{types.map(({ name, count }) => {
const active = highlighted.has(name);
return (
<button
key={name}
onClick={() => toggleType(name)}
aria-pressed={active}
title={`${active ? 'Unhighlight' : 'Highlight'} ${name}`}
className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs transition-colors ${
active
? 'border-zinc-300 bg-zinc-700 text-zinc-100'
: 'border-zinc-800 text-zinc-400 hover:border-zinc-600 hover:text-zinc-200'
}`}
>
<span className="inline-flex items-center gap-0.5">
{iconsForObject(name).map((icon) => (
<Icon key={icon} icon={icon} className="h-3.5 w-3.5" />
))}
</span>
<span>{name}</span>
<span className="text-zinc-500">{count}</span>
</button>
);
})}
{highlighted.size > 0 && (
<button
onClick={clearAll}
className="ml-auto inline-flex items-center rounded px-1.5 py-0.5 text-xs text-zinc-500 hover:text-zinc-200"
>
Clear
</button>
)}
</div>
)}
<ul className="space-y-0.5">
{visibleNodes.map(({ node, path }) => (
<TreeNode
key={path}
node={node}
depth={0}
path={path}
selectedPath={selectedPath}
onSelect={onSelect}
/>
))}
</ul>
</div>
);
}
@@ -103,3 +162,50 @@ function TreeNode({
</li>
);
}
/** Distinct object types present in the tree, with a count of each. */
function collectTypes(nodes: ObjectTreeNode[]): { name: string; count: number }[] {
const counts = new Map<string, number>();
const visit = (node: ObjectTreeNode) => {
counts.set(node.object.Name, (counts.get(node.object.Name) ?? 0) + 1);
node.children.forEach(visit);
};
nodes.forEach(visit);
return [...counts.entries()]
.map(([name, count]) => ({ name, count }))
.sort((a, b) => a.name.localeCompare(b.name));
}
/**
* Keep a node when its type is highlighted or any descendant survives, so the
* containment hierarchy is preserved and highlighted parents still lead to
* highlighted children. With nothing highlighted, every node is kept.
*
* Each result carries the node's original index path into the full (unfiltered)
* tree, so selection stays stable regardless of filtering.
*/
function filterTree(
nodes: ObjectTreeNode[],
highlighted: Set<string>,
prefix = '',
): { node: ObjectTreeNode; path: string }[] {
const result: { node: ObjectTreeNode; path: string }[] = [];
nodes.forEach((node, index) => {
const path = prefix ? `${prefix}-${index}` : `${index}`;
const children = filterTree(node.children, highlighted, path);
const visible =
highlighted.size === 0 ||
highlighted.has(node.object.Name) ||
children.length > 0;
if (visible) {
result.push({
node:
children.length > 0
? { ...node, children: children.map((c) => c.node) }
: node,
path,
});
}
});
return result;
}