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.
211 lines
6.7 KiB
TypeScript
211 lines
6.7 KiB
TypeScript
import { useMemo, useState } from 'react';
|
|
import { Icon } from '@iconify/react';
|
|
import type { ObjectTreeNode } from '@tts/extract';
|
|
import { iconsForObject } from './objectIcons';
|
|
|
|
interface Props {
|
|
nodes: ObjectTreeNode[];
|
|
selectedPath: string | null;
|
|
onSelect: (path: string) => void;
|
|
}
|
|
|
|
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 (
|
|
<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>
|
|
);
|
|
}
|
|
|
|
function TreeNode({
|
|
node,
|
|
depth,
|
|
path,
|
|
selectedPath,
|
|
onSelect,
|
|
}: {
|
|
node: ObjectTreeNode;
|
|
depth: number;
|
|
/** Index path from the root, used as a stable unique key and selection id. */
|
|
path: string;
|
|
selectedPath: string | null;
|
|
onSelect: (path: string) => void;
|
|
}) {
|
|
// Selection is keyed by the node's unique index path, not its GUID: cards in
|
|
// a deck frequently share a GUID (the deck's), so GUID-based selection would
|
|
// highlight and render the wrong card.
|
|
const selected = path === selectedPath;
|
|
const hasChildren = node.children.length > 0;
|
|
const [expanded, setExpanded] = useState(false);
|
|
return (
|
|
<li>
|
|
<div
|
|
className={`flex items-center rounded pr-2 ${
|
|
selected
|
|
? 'bg-zinc-700 text-zinc-100'
|
|
: 'text-zinc-300 hover:bg-zinc-800 hover:text-zinc-100'
|
|
}`}
|
|
style={{ paddingLeft: `${depth * 16 + 4}px` }}
|
|
>
|
|
{hasChildren ? (
|
|
<button
|
|
onClick={() => setExpanded((e) => !e)}
|
|
aria-label={expanded ? 'Collapse' : 'Expand'}
|
|
className="flex h-6 w-6 shrink-0 items-center justify-center rounded text-zinc-500 hover:text-zinc-200"
|
|
>
|
|
<span
|
|
className={`inline-block text-xs transition-transform ${expanded ? 'rotate-90' : ''}`}
|
|
>
|
|
▸
|
|
</span>
|
|
</button>
|
|
) : (
|
|
<span className="h-6 w-6 shrink-0" />
|
|
)}
|
|
<button
|
|
onClick={() => onSelect(path)}
|
|
className="block min-w-0 flex-1 truncate py-1 text-left text-sm"
|
|
>
|
|
<span
|
|
title={node.object.Name}
|
|
className="mr-1.5 inline-flex items-center gap-0.5 align-middle text-zinc-400"
|
|
>
|
|
{iconsForObject(node.object.Name).map((icon) => (
|
|
<Icon key={icon} icon={icon} className="h-4 w-4" />
|
|
))}
|
|
</span>
|
|
<span className="truncate">{node.label}</span>
|
|
</button>
|
|
</div>
|
|
{hasChildren && expanded && (
|
|
<ul>
|
|
{node.children.map((child, index) => (
|
|
<TreeNode
|
|
key={`${path}-${index}`}
|
|
node={child}
|
|
depth={depth + 1}
|
|
path={`${path}-${index}`}
|
|
selectedPath={selectedPath}
|
|
onSelect={onSelect}
|
|
/>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</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;
|
|
} |