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 { Icon } from '@iconify/react';
|
||||||
import type { ObjectTreeNode } from '@tts/extract';
|
import type { ObjectTreeNode } from '@tts/extract';
|
||||||
import { iconsForObject } from './objectIcons';
|
import { iconsForObject } from './objectIcons';
|
||||||
@@ -10,19 +10,78 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ObjectTree({ nodes, selectedPath, onSelect }: 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 (
|
return (
|
||||||
<ul className="space-y-0.5">
|
<div className="space-y-2">
|
||||||
{nodes.map((node, index) => (
|
{types.length > 0 && (
|
||||||
<TreeNode
|
<div className="flex flex-wrap gap-1 border-b border-zinc-800 pb-2">
|
||||||
key={index}
|
{types.map(({ name, count }) => {
|
||||||
node={node}
|
const active = highlighted.has(name);
|
||||||
depth={0}
|
return (
|
||||||
path={`${index}`}
|
<button
|
||||||
selectedPath={selectedPath}
|
key={name}
|
||||||
onSelect={onSelect}
|
onClick={() => toggleType(name)}
|
||||||
/>
|
aria-pressed={active}
|
||||||
))}
|
title={`${active ? 'Unhighlight' : 'Highlight'} ${name}`}
|
||||||
</ul>
|
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>
|
</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