fix(web): select tree nodes by index path instead of GUID

Cards in a deck share the deck's GUID, so GUID-based selection highlighted
every card with that GUID and rendered the first match. Key selection by the
node's unique index path instead.
This commit is contained in:
2026-08-08 17:42:16 +08:00
parent 835250abdd
commit 55e0351c1d
2 changed files with 28 additions and 24 deletions
+14 -11
View File
@@ -5,11 +5,11 @@ import { iconsForObject } from './objectIcons';
interface Props { interface Props {
nodes: ObjectTreeNode[]; nodes: ObjectTreeNode[];
selectedGuid: string | null; selectedPath: string | null;
onSelect: (guid: string) => void; onSelect: (path: string) => void;
} }
export default function ObjectTree({ nodes, selectedGuid, onSelect }: Props) { export default function ObjectTree({ nodes, selectedPath, onSelect }: Props) {
return ( return (
<ul className="space-y-0.5"> <ul className="space-y-0.5">
{nodes.map((node, index) => ( {nodes.map((node, index) => (
@@ -18,7 +18,7 @@ export default function ObjectTree({ nodes, selectedGuid, onSelect }: Props) {
node={node} node={node}
depth={0} depth={0}
path={`${index}`} path={`${index}`}
selectedGuid={selectedGuid} selectedPath={selectedPath}
onSelect={onSelect} onSelect={onSelect}
/> />
))} ))}
@@ -30,17 +30,20 @@ function TreeNode({
node, node,
depth, depth,
path, path,
selectedGuid, selectedPath,
onSelect, onSelect,
}: { }: {
node: ObjectTreeNode; node: ObjectTreeNode;
depth: number; depth: number;
/** Index path from the root, used as a stable unique key. */ /** Index path from the root, used as a stable unique key and selection id. */
path: string; path: string;
selectedGuid: string | null; selectedPath: string | null;
onSelect: (guid: string) => void; onSelect: (path: string) => void;
}) { }) {
const selected = node.object.GUID === selectedGuid; // 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 hasChildren = node.children.length > 0;
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
return ( return (
@@ -69,7 +72,7 @@ function TreeNode({
<span className="h-6 w-6 shrink-0" /> <span className="h-6 w-6 shrink-0" />
)} )}
<button <button
onClick={() => onSelect(node.object.GUID)} onClick={() => onSelect(path)}
className="block min-w-0 flex-1 truncate py-1 text-left text-sm" className="block min-w-0 flex-1 truncate py-1 text-left text-sm"
> >
<span <span
@@ -91,7 +94,7 @@ function TreeNode({
node={child} node={child}
depth={depth + 1} depth={depth + 1}
path={`${path}-${index}`} path={`${path}-${index}`}
selectedGuid={selectedGuid} selectedPath={selectedPath}
onSelect={onSelect} onSelect={onSelect}
/> />
))} ))}
+14 -13
View File
@@ -16,7 +16,10 @@ export default function ModPage() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const { mod, loading, error, load } = useModStore(); const { mod, loading, error, load } = useModStore();
const item = useSearchStore((s) => s.items.find((i) => i.id === id)); const item = useSearchStore((s) => s.items.find((i) => i.id === id));
const [selectedGuid, setSelectedGuid] = useState<string | null>(null); // Selection is keyed by the node's index path, not its GUID: cards in a
// deck share the deck's GUID, so GUID-based selection would resolve to the
// wrong card.
const [selectedPath, setSelectedPath] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
if (id) load(id, item?.fileUrl); if (id) load(id, item?.fileUrl);
@@ -26,8 +29,8 @@ export default function ModPage() {
const refs = useMemo(() => (mod ? collectRefs(mod) : []), [mod]); const refs = useMemo(() => (mod ? collectRefs(mod) : []), [mod]);
const selected = useMemo( const selected = useMemo(
() => (mod ? findInTree(tree, selectedGuid) : undefined), () => (mod ? findInTree(tree, selectedPath) : undefined),
[tree, selectedGuid], [tree, selectedPath],
); );
if (loading) return <p className="text-sm text-zinc-400">Loading mod</p>; if (loading) return <p className="text-sm text-zinc-400">Loading mod</p>;
@@ -56,8 +59,8 @@ export default function ModPage() {
<aside className="rounded-lg border border-zinc-800 bg-zinc-900 p-2"> <aside className="rounded-lg border border-zinc-800 bg-zinc-900 p-2">
<ObjectTree <ObjectTree
nodes={tree} nodes={tree}
selectedGuid={selectedGuid} selectedPath={selectedPath}
onSelect={setSelectedGuid} onSelect={setSelectedPath}
/> />
</aside> </aside>
@@ -105,13 +108,11 @@ export default function ModPage() {
function findInTree( function findInTree(
nodes: ReturnType<typeof buildTree>, nodes: ReturnType<typeof buildTree>,
guid: string | null, path: string | null,
): ReturnType<typeof buildTree>[number] | undefined { ): ReturnType<typeof buildTree>[number] | undefined {
if (!guid) return undefined; if (!path) return undefined;
for (const node of nodes) { const [head, ...rest] = path.split('-');
if (node.object.GUID === guid) return node; const node = nodes[Number(head)];
const found = findInTree(node.children, guid); if (!node) return undefined;
if (found) return found; return rest.length === 0 ? node : findInTree(node.children, rest.join('-'));
}
return undefined;
} }