feat(web-platform): release V0.2
build / setup (compute matrix) (pull_request) Has been cancelled
build / macos-15-arm64-studio (pull_request) Has been cancelled
build / ubuntu-24.04-clang-18-studio (pull_request) Has been cancelled
build / ubuntu-24.04-gcc-14-studio (pull_request) Has been cancelled
build / windows-2025-ninja-studio (pull_request) Has been cancelled
build / ubuntu-24.04-clang-18-wasm (pull_request) Has been cancelled
build / ubuntu-24.04-clang-18-mjx (pull_request) Has been cancelled
lint / pre-commit (pull_request) Has been cancelled
build / ${{ matrix.label }} (pull_request) Has been cancelled
build / setup (compute matrix) (pull_request) Has been cancelled
build / macos-15-arm64-studio (pull_request) Has been cancelled
build / ubuntu-24.04-clang-18-studio (pull_request) Has been cancelled
build / ubuntu-24.04-gcc-14-studio (pull_request) Has been cancelled
build / windows-2025-ninja-studio (pull_request) Has been cancelled
build / ubuntu-24.04-clang-18-wasm (pull_request) Has been cancelled
build / ubuntu-24.04-clang-18-mjx (pull_request) Has been cancelled
lint / pre-commit (pull_request) Has been cancelled
build / ${{ matrix.label }} (pull_request) Has been cancelled
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import {fireEvent,render,screen} from '@testing-library/react';
|
||||
import {buildBodyTree,ModelStructureTree} from './ModelStructureTree';
|
||||
import {buildBodyTree,countModelStructureSearchResults,ModelStructureTree} from './ModelStructureTree';
|
||||
import type {BodyInfo,JointInfo} from '../simulation/SimulationSession';
|
||||
|
||||
const bodies:BodyInfo[]=[{id:0,name:'world',parentId:0},{id:1,name:'base',parentId:0},{id:2,name:'arm',parentId:1}];
|
||||
@@ -14,7 +14,11 @@ describe('ModelStructureTree',()=>{
|
||||
});
|
||||
|
||||
it('鼠标进入和离开关节时通知查看器高亮',()=>{
|
||||
const hover=vi.fn();render(<ModelStructureTree bodies={bodies} joints={joints} onJointHover={hover}/>);const item=screen.getByRole('treeitem',{name:/arm_joint/});
|
||||
const hover=vi.fn();render(<ModelStructureTree bodies={bodies} joints={joints} onJointHover={hover}/>);expect(screen.getByRole('treeitem',{name:'base'})).toHaveAttribute('aria-expanded','true');const item=screen.getByRole('treeitem',{name:/arm_joint/});
|
||||
fireEvent.mouseEnter(item);fireEvent.mouseLeave(item);expect(hover.mock.calls).toEqual([[0],[null]]);
|
||||
});
|
||||
|
||||
it('按 Body 或关节名称过滤并保留祖先路径',()=>{
|
||||
render(<ModelStructureTree bodies={bodies} joints={joints} query="arm_joint" onJointHover={()=>{}}/>);expect(screen.getByRole('treeitem',{name:'base'})).toBeVisible();expect(screen.getByRole('treeitem',{name:/arm_joint/})).toBeVisible();expect(countModelStructureSearchResults(bodies,joints,'arm_joint')).toBe(3);expect(countModelStructureSearchResults(bodies,joints,'world')).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import {useState} from 'react';
|
||||
import {Box,Disc3} from 'lucide-react';
|
||||
import type {BodyInfo,JointInfo} from '../simulation/SimulationSession';
|
||||
import {EmptySearchState,SearchHighlight,VirtualTreeViewport} from '../components/ui';
|
||||
|
||||
interface BodyNode extends BodyInfo {children:BodyNode[];joints:JointInfo[];}
|
||||
|
||||
@@ -11,12 +13,20 @@ export function buildBodyTree(bodies:BodyInfo[],joints:JointInfo[]):BodyNode[]{
|
||||
const sort=(items:BodyNode[])=>{items.sort((a,b)=>a.id-b.id);for(const item of items)sort(item.children);};sort(roots);return roots;
|
||||
}
|
||||
|
||||
function BodyBranch({node,depth,onJointHover}:{node:BodyNode;depth:number;onJointHover:(jointId:number|null)=>void}){
|
||||
const hasChildren=node.joints.length>0||node.children.length>0;const [open,setOpen]=useState(depth<2);
|
||||
return <li>{hasChildren?<details open={open} onToggle={event=>setOpen(event.currentTarget.open)}><summary className="cursor-pointer select-none truncate rounded px-1.5 py-1 text-xs text-slate-300 hover:bg-slate-800"><span aria-hidden="true" className="mr-1 text-sky-400">◆</span>{node.name}</summary><ul role="group" className="ml-3 border-l border-slate-800 pl-1">{node.joints.map(joint=><li key={joint.id}><span role="treeitem" tabIndex={0} className="block cursor-default truncate rounded px-1.5 py-1 text-xs text-amber-300 hover:bg-amber-950/40 hover:text-amber-200 focus:bg-amber-950/40 focus:outline-none" onMouseEnter={()=>onJointHover(joint.id)} onMouseLeave={()=>onJointHover(null)} onFocus={()=>onJointHover(joint.id)} onBlur={()=>onJointHover(null)} title={`关节:${joint.name}`}><span aria-hidden="true" className="mr-1">○</span>{joint.name}</span></li>)}{node.children.map(child=><BodyBranch key={child.id} node={child} depth={depth+1} onJointHover={onJointHover}/>)}</ul></details>:<div className="truncate rounded px-1.5 py-1 text-xs text-slate-300"><span aria-hidden="true" className="mr-1 text-sky-400">◆</span>{node.name}</div>}</li>;
|
||||
function BodyBranch({node,depth,onJointHover,searching,query}:{node:BodyNode;depth:number;onJointHover:(jointId:number|null)=>void;searching:boolean;query:string}){
|
||||
const hasChildren=node.joints.length>0||node.children.length>0;const [open,setOpen]=useState(depth<2),shownOpen=searching||open;
|
||||
return <li role="none">{hasChildren?<details open={shownOpen} onToggle={event=>{if(!searching)setOpen(event.currentTarget.open);}}><summary role="treeitem" aria-expanded={shownOpen} tabIndex={0} onClick={event=>{if(searching)event.preventDefault();}} className="flex cursor-pointer select-none items-center gap-1.5 truncate rounded px-1.5 py-1 text-xs text-text-secondary hover:bg-element-hover focus-visible:ring-2 focus-visible:ring-accent/30"><Box aria-hidden="true" className="h-3.5 w-3.5 shrink-0 text-accent"/><span className="truncate"><SearchHighlight text={node.name} query={query}/></span></summary><ul role="group" className="ml-3 border-l border-border pl-1">{node.joints.map(joint=><li role="none" key={joint.id}><span role="treeitem" tabIndex={0} className="flex cursor-default items-center gap-1.5 truncate rounded px-1.5 py-1 text-xs text-warning hover:bg-warning-soft focus:bg-warning-soft focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/30" onMouseEnter={()=>onJointHover(joint.id)} onMouseLeave={()=>onJointHover(null)} onFocus={()=>onJointHover(joint.id)} onBlur={()=>onJointHover(null)} title={`关节:${joint.name}`}><Disc3 aria-hidden="true" className="h-3.5 w-3.5 shrink-0"/><SearchHighlight text={joint.name} query={query}/></span></li>)}{node.children.map(child=><BodyBranch key={child.id} node={child} depth={depth+1} onJointHover={onJointHover} searching={searching} query={query}/>)}</ul></details>:<div role="treeitem" tabIndex={0} className="flex items-center gap-1.5 truncate rounded px-1.5 py-1 text-xs text-text-secondary focus-visible:ring-2 focus-visible:ring-accent/30"><Box aria-hidden="true" className="h-3.5 w-3.5 shrink-0 text-accent"/><SearchHighlight text={node.name} query={query}/></div>}</li>;
|
||||
}
|
||||
|
||||
export function ModelStructureTree({bodies,joints,onJointHover}:{bodies:BodyInfo[];joints:JointInfo[];onJointHover:(jointId:number|null)=>void}){
|
||||
const roots=buildBodyTree(bodies,joints);
|
||||
return <nav aria-label="模型结构树"><ul role="tree">{roots.map(root=><BodyBranch key={root.id} node={root} depth={0} onJointHover={onJointHover}/>)}</ul></nav>;
|
||||
function filterBodies(nodes:BodyNode[],query:string):BodyNode[]{if(!query)return nodes;return nodes.flatMap(node=>{if(node.name.toLocaleLowerCase().includes(query))return [node];const joints=node.joints.filter(joint=>joint.name.toLocaleLowerCase().includes(query)),children=filterBodies(node.children,query);return joints.length||children.length?[{...node,joints,children}]:[];});}
|
||||
function countBodyNodes(nodes:BodyNode[]):number{return nodes.reduce((total,node)=>total+1+node.joints.length+countBodyNodes(node.children),0);}
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export function countModelStructureSearchResults(bodies:BodyInfo[],joints:JointInfo[],query:string):number{return countBodyNodes(filterBodies(buildBodyTree(bodies,joints),query.trim().toLocaleLowerCase()));}
|
||||
type FlatBodyItem={kind:'body';body:BodyNode;depth:number}|{kind:'joint';joint:JointInfo;depth:number};
|
||||
function flattenBodies(nodes:BodyNode[],expanded:Set<number>,searching:boolean,depth=0):FlatBodyItem[]{return nodes.flatMap(body=>[{kind:'body' as const,body,depth},...(searching||expanded.has(body.id)?[...body.joints.map(joint=>({kind:'joint' as const,joint,depth:depth+1})),...flattenBodies(body.children,expanded,searching,depth+1)]:[])]);}
|
||||
function initiallyExpanded(nodes:BodyNode[],depth=0):number[]{return nodes.flatMap(body=>[...(depth<2?[body.id]:[]),...initiallyExpanded(body.children,depth+1)]);}
|
||||
export function ModelStructureTree({bodies,joints,onJointHover,query=''}:{bodies:BodyInfo[];joints:JointInfo[];onJointHover:(jointId:number|null)=>void;query?:string}){
|
||||
const normalized=query.trim().toLocaleLowerCase(),roots=filterBodies(buildBodyTree(bodies,joints),normalized),[virtualExpanded,setVirtualExpanded]=useState(()=>new Set(initiallyExpanded(buildBodyTree(bodies,joints))));
|
||||
if(bodies.length+joints.length>500&&roots.length){const searching=Boolean(normalized),flat=flattenBodies(roots,virtualExpanded,searching),toggle=(item:FlatBodyItem)=>{if(item.kind!=='body'||searching)return;setVirtualExpanded(current=>{const next=new Set(current);if(next.has(item.body.id))next.delete(item.body.id);else next.add(item.body.id);return next;});};return <nav aria-label="模型结构树"><VirtualTreeViewport label="虚拟化模型结构树" items={flat} getKey={item=>item.kind==='body'?`b:${item.body.id}`:`j:${item.joint.id}`} getLevel={item=>item.depth+1} isExpandable={item=>item.kind==='body'&&(item.body.joints.length>0||item.body.children.length>0)} isExpanded={item=>item.kind==='body'&&(searching||virtualExpanded.has(item.body.id))} onToggle={toggle} onActiveChange={item=>onJointHover(item.kind==='joint'?item.joint.id:null)} renderRow={item=>item.kind==='body'?<div onDoubleClick={()=>toggle(item)} className="flex h-full items-center gap-1.5 px-1.5 text-xs text-text-secondary" style={{paddingLeft:item.depth*12+6}}><Box className="h-3.5 w-3.5 text-accent"/><SearchHighlight text={item.body.name} query={query}/></div>:<div className="flex h-full items-center gap-1.5 px-1.5 text-xs text-warning" style={{paddingLeft:item.depth*12+6}} onMouseEnter={()=>onJointHover(item.joint.id)} onMouseLeave={()=>onJointHover(null)}><Disc3 className="h-3.5 w-3.5"/><SearchHighlight text={item.joint.name} query={query}/></div>}/></nav>;}
|
||||
return <nav aria-label="模型结构树">{roots.length?<ul role="tree">{roots.map(root=><BodyBranch key={root.id} node={root} depth={0} onJointHover={onJointHover} searching={Boolean(normalized)} query={query}/>)}</ul>:<EmptySearchState label="没有匹配的 Body 或关节"/>}</nav>;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {fireEvent,render,screen,within} from '@testing-library/react';
|
||||
import {buildProjectTree,ProjectTree} from './ProjectTree';
|
||||
import {buildProjectTree,countProjectSearchResults,ProjectTree} from './ProjectTree';
|
||||
|
||||
const files=[
|
||||
{path:'robot/meshes/arm.obj',size:2048},
|
||||
@@ -29,4 +29,11 @@ describe('ProjectTree',()=>{
|
||||
expect(within(tree).queryByText('robot/meshes/arm.obj')).not.toBeInTheDocument();
|
||||
expect(within(tree).getByText('urdf')).toBeVisible();
|
||||
});
|
||||
|
||||
it('搜索时只保留匹配文件及其目录路径',()=>{
|
||||
render(<ProjectTree files={files} entries={[]} query="arm.obj"/>);
|
||||
expect(screen.getByText('robot')).toBeVisible();expect(screen.getByText('meshes')).toBeVisible();expect(screen.getByText('arm.obj')).toBeVisible();expect(screen.queryByText('README.txt')).not.toBeInTheDocument();expect(countProjectSearchResults(files,'meshes')).toBe(3);expect(countProjectSearchResults(files,'robot/meshes')).toBe(0);
|
||||
});
|
||||
|
||||
it('大型工程使用可键盘折叠的虚拟树',()=>{const large=Array.from({length:401},(_,index)=>({path:`assets/file-${index}.obj`,size:1}));render(<ProjectTree files={large} entries={[]}/>);const tree=screen.getByRole('tree',{name:'虚拟化工程文件树'});expect(tree).toHaveAttribute('aria-activedescendant',expect.stringContaining('assets'));fireEvent.keyDown(tree,{key:'ArrowLeft'});expect(screen.queryByText('file-0.obj')).not.toBeInTheDocument();});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import {useState} from 'react';
|
||||
import {Box,File,FileCode2,Folder,FolderOpen} from 'lucide-react';
|
||||
import type {ModelEntry} from './types';
|
||||
import {EmptySearchState,SearchHighlight,VirtualTreeViewport} from '../components/ui';
|
||||
|
||||
export interface ProjectTreeFile {path:string;size:number;}
|
||||
|
||||
@@ -53,15 +55,22 @@ function formatSize(bytes:number):string {
|
||||
return `${(bytes/(1024*1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
interface TreeNodeProps {entryFormats:Map<string,ModelEntry['format']>;selectedEntry?:string;expandedEntry?:string;}
|
||||
function DirectoryNode({node,entryFormats,selectedEntry,expandedEntry}:TreeNodeProps&{node:ProjectTreeNode}){const [open,setOpen]=useState(Boolean(expandedEntry?.startsWith(`${node.path}/`)));return <li><details open={open} onToggle={event=>setOpen(event.currentTarget.open)}><summary title={node.path} className="cursor-pointer select-none truncate rounded px-1.5 py-1 text-xs text-slate-300 hover:bg-slate-800"><span aria-hidden="true" className="mr-1">📁</span>{node.name}</summary><TreeNodes nodes={node.children??[]} entryFormats={entryFormats} selectedEntry={selectedEntry} expandedEntry={expandedEntry}/></details></li>;}
|
||||
function TreeNodes({nodes,entryFormats,selectedEntry,expandedEntry}:TreeNodeProps&{nodes:ProjectTreeNode[]}){
|
||||
return <ul role="group" className="ml-3 border-l border-slate-800 pl-1">{nodes.map(node=>node.kind==='directory'?<DirectoryNode key={`d:${node.path}`} node={node} entryFormats={entryFormats} selectedEntry={selectedEntry} expandedEntry={expandedEntry}/>:<li key={`f:${node.path}`} title={node.path} className={`flex min-w-0 items-center gap-1 rounded px-1.5 py-1 text-xs ${selectedEntry===node.path?'bg-green-900/40 text-green-200':'text-slate-300 hover:bg-slate-800'}`}><span aria-hidden="true">{entryFormats.has(node.path)?'◇':'▧'}</span><span className="min-w-0 flex-1 truncate">{node.name}</span>{entryFormats.has(node.path)&&<span className="shrink-0 text-[10px] uppercase text-green-400">{entryFormats.get(node.path)}</span>}<span className="shrink-0 text-[10px] text-slate-500">{formatSize(node.size??0)}</span></li>)}</ul>;
|
||||
interface TreeNodeProps {entryFormats:Map<string,ModelEntry['format']>;selectedEntry?:string;expandedEntry?:string;searching:boolean;query:string;}
|
||||
function DirectoryNode({node,entryFormats,selectedEntry,expandedEntry,searching,query}:TreeNodeProps&{node:ProjectTreeNode}){const [open,setOpen]=useState(Boolean(expandedEntry?.startsWith(`${node.path}/`)));const shownOpen=searching||open,FolderIcon=shownOpen?FolderOpen:Folder;return <li><details open={shownOpen} onToggle={event=>{if(!searching)setOpen(event.currentTarget.open);}}><summary title={node.path} onClick={event=>{if(searching)event.preventDefault();}} className="flex cursor-pointer select-none items-center gap-1.5 truncate rounded px-1.5 py-1 text-xs text-text-secondary hover:bg-element-hover"><FolderIcon aria-hidden="true" className="h-3.5 w-3.5 shrink-0 text-accent"/><span className="truncate"><SearchHighlight text={node.name} query={query}/></span></summary><TreeNodes nodes={node.children??[]} entryFormats={entryFormats} selectedEntry={selectedEntry} expandedEntry={expandedEntry} searching={searching} query={query}/></details></li>;}
|
||||
function TreeNodes({nodes,entryFormats,selectedEntry,expandedEntry,searching,query}:TreeNodeProps&{nodes:ProjectTreeNode[]}){
|
||||
return <ul role="group" className="ml-3 border-l border-border pl-1">{nodes.map(node=>{if(node.kind==='directory')return <DirectoryNode key={`d:${node.path}`} node={node} entryFormats={entryFormats} selectedEntry={selectedEntry} expandedEntry={expandedEntry} searching={searching} query={query}/>;const EntryIcon=entryFormats.has(node.path)?FileCode2:node.path.endsWith('.obj')||node.path.endsWith('.stl')||node.path.endsWith('.dae')?Box:File;return <li key={`f:${node.path}`} title={node.path} className={`flex min-w-0 items-center gap-1.5 rounded px-1.5 py-1 text-xs ${selectedEntry===node.path?'bg-accent-soft text-accent':'text-text-secondary hover:bg-element-hover'}`}><EntryIcon aria-hidden="true" className="h-3.5 w-3.5 shrink-0"/><span className="min-w-0 flex-1 truncate"><SearchHighlight text={node.name} query={query}/></span>{entryFormats.has(node.path)&&<span className="shrink-0 text-[10px] uppercase text-accent">{entryFormats.get(node.path)}</span>}<span className="shrink-0 text-[10px] text-text-tertiary">{formatSize(node.size??0)}</span></li>;})}</ul>;
|
||||
}
|
||||
|
||||
export function ProjectTree({files,entries,selectedEntry}:{files:ProjectTreeFile[];entries:ModelEntry[];selectedEntry?:string}){
|
||||
const nodes=buildProjectTree(files);
|
||||
function filterNodes(nodes:ProjectTreeNode[],query:string):ProjectTreeNode[]{if(!query)return nodes;return nodes.flatMap(node=>{if(node.name.toLocaleLowerCase().includes(query))return [node];if(node.kind==='file')return [];const children=filterNodes(node.children??[],query);return children.length?[{...node,children}]:[];});}
|
||||
function countNodes(nodes:ProjectTreeNode[]):number{return nodes.reduce((total,node)=>total+1+(node.children?countNodes(node.children):0),0);}
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export function countProjectSearchResults(files:ProjectTreeFile[],query:string):number{return countNodes(filterNodes(buildProjectTree(files),query.trim().toLocaleLowerCase()));}
|
||||
interface FlatProjectNode{node:ProjectTreeNode;depth:number;}
|
||||
function flattenProjectNodes(nodes:ProjectTreeNode[],expanded:Set<string>,searching:boolean,depth=0):FlatProjectNode[]{return nodes.flatMap(node=>[{node,depth},...(node.kind==='directory'&&(searching||expanded.has(node.path))?flattenProjectNodes(node.children??[],expanded,searching,depth+1):[])]);}
|
||||
export function ProjectTree({files,entries,selectedEntry,query=''}:{files:ProjectTreeFile[];entries:ModelEntry[];selectedEntry?:string;query?:string}){
|
||||
const normalized=query.trim().toLocaleLowerCase(),nodes=filterNodes(buildProjectTree(files),normalized),[virtualExpanded,setVirtualExpanded]=useState(()=>new Set(buildProjectTree(files).filter(node=>node.kind==='directory').map(node=>node.path)));
|
||||
const entryFormats=new Map(entries.map(entry=>[entry.path,entry.format]));
|
||||
const expandedEntry=entries.some(entry=>entry.path===selectedEntry&&entry.format==='urdf')?selectedEntry:undefined;
|
||||
return <nav aria-label="工程文件树"><TreeNodes key={expandedEntry??'collapsed'} nodes={nodes} entryFormats={entryFormats} selectedEntry={selectedEntry} expandedEntry={expandedEntry}/></nav>;
|
||||
if(files.length>400&&nodes.length){const searching=Boolean(normalized),flat=flattenProjectNodes(nodes,virtualExpanded,searching),toggle=(item:FlatProjectNode)=>{if(item.node.kind!=='directory'||searching)return;setVirtualExpanded(current=>{const next=new Set(current);if(next.has(item.node.path))next.delete(item.node.path);else next.add(item.node.path);return next;});};return <nav aria-label="工程文件树"><VirtualTreeViewport label="虚拟化工程文件树" items={flat} getKey={item=>item.node.path} getLevel={item=>item.depth+1} isExpandable={item=>item.node.kind==='directory'&&(item.node.children?.length??0)>0} isExpanded={item=>searching||virtualExpanded.has(item.node.path)} onToggle={toggle} renderRow={({node,depth})=>{const directory=node.kind==='directory',opened=searching||virtualExpanded.has(node.path),EntryIcon=directory?(opened?FolderOpen:Folder):entryFormats.has(node.path)?FileCode2:node.path.endsWith('.obj')||node.path.endsWith('.stl')||node.path.endsWith('.dae')?Box:File;return <div title={node.path} onDoubleClick={()=>toggle({node,depth})} className={`flex h-full items-center gap-1.5 rounded px-1.5 text-xs ${selectedEntry===node.path?'bg-accent-soft text-accent':'text-text-secondary hover:bg-element-hover'}`} style={{paddingLeft:depth*12+6}}><EntryIcon className="h-3.5 w-3.5 shrink-0"/><span className="min-w-0 flex-1 truncate"><SearchHighlight text={node.name} query={query}/></span>{!directory&&<span className="text-[10px] text-text-tertiary">{formatSize(node.size??0)}</span>}</div>;}}/></nav>;}
|
||||
return <nav aria-label="工程文件树">{nodes.length?<TreeNodes key={expandedEntry??'collapsed'} nodes={nodes} entryFormats={entryFormats} selectedEntry={selectedEntry} expandedEntry={expandedEntry} searching={Boolean(normalized)} query={query}/>:<EmptySearchState label="没有匹配的文件"/>}</nav>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user