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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -102,4 +161,51 @@ 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;
|
||||
}
|
||||
Reference in New Issue
Block a user