feat(web-platform): release V0.8.3 常规界面优化
web-platform-release / Build and publish release (push) Has been cancelled
web-platform-ci / TypeScript, lint, unit, build (push) Has been cancelled
web-platform-ci / Playwright E2E (push) Has been cancelled
web-platform-ci / TypeScript, lint, unit, build (pull_request) Has been cancelled
web-platform-ci / Playwright E2E (pull_request) Has been cancelled

This commit is contained in:
2026-09-04 15:34:24 +08:00
parent 63d67a645b
commit fa5485049a
60 changed files with 4494 additions and 2073 deletions
+57 -11
View File
@@ -36,12 +36,20 @@ function BodyBranch({
node,
depth,
onJointHover,
onSelectBody,
onSelectJoint,
selectedBodyId,
selectedJointId,
searching,
query,
}: {
node: BodyNode;
depth: number;
onJointHover: (jointId: number | null) => void;
onSelectBody?: (bodyId: number) => void;
onSelectJoint?: (joint: JointInfo) => void;
selectedBodyId?: number;
selectedJointId?: number;
searching: boolean;
query: string;
}) {
@@ -60,11 +68,15 @@ function BodyBranch({
<summary
role="treeitem"
aria-expanded={shownOpen}
aria-selected={selectedBodyId === node.id}
tabIndex={0}
onClick={(event) => {
onSelectBody?.(node.id);
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"
className={`flex cursor-pointer select-none items-center gap-1.5 truncate rounded px-1.5 py-1 text-xs hover:bg-element-hover focus-visible:ring-2 focus-visible:ring-accent/30 ${
selectedBodyId === node.id ? 'bg-accent-soft text-accent' : 'text-text-secondary'
}`}
>
<Box aria-hidden="true" className="h-3.5 w-3.5 shrink-0 text-accent" />
<span className="truncate">
@@ -74,10 +86,14 @@ function BodyBranch({
<ul role="group" className="ml-3 border-l border-border pl-1">
{node.joints.map((joint) => (
<li role="none" key={joint.id}>
<span
<button
type="button"
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"
aria-selected={selectedJointId === joint.id}
className={`flex w-full items-center gap-1.5 truncate rounded px-1.5 py-1 text-left text-xs text-warning hover:bg-warning-soft focus:bg-warning-soft focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/30 ${
selectedJointId === joint.id ? 'bg-warning-soft' : ''
}`}
onClick={() => onSelectJoint?.(joint)}
onMouseEnter={() => onJointHover(joint.id)}
onMouseLeave={() => onJointHover(null)}
onFocus={() => onJointHover(joint.id)}
@@ -86,7 +102,7 @@ function BodyBranch({
>
<Disc3 aria-hidden="true" className="h-3.5 w-3.5 shrink-0" />
<SearchHighlight text={joint.name} query={query} />
</span>
</button>
</li>
))}
{node.children.map((child) => (
@@ -95,6 +111,10 @@ function BodyBranch({
node={child}
depth={depth + 1}
onJointHover={onJointHover}
onSelectBody={onSelectBody}
onSelectJoint={onSelectJoint}
selectedBodyId={selectedBodyId}
selectedJointId={selectedJointId}
searching={searching}
query={query}
/>
@@ -102,14 +122,20 @@ function BodyBranch({
</ul>
</details>
) : (
<div
<button
type="button"
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"
aria-selected={selectedBodyId === node.id}
className={`flex w-full items-center gap-1.5 truncate rounded px-1.5 py-1 text-left text-xs focus-visible:ring-2 focus-visible:ring-accent/30 ${
selectedBodyId === node.id
? 'bg-accent-soft text-accent'
: 'text-text-secondary hover:bg-element-hover'
}`}
onClick={() => onSelectBody?.(node.id)}
>
<Box aria-hidden="true" className="h-3.5 w-3.5 shrink-0 text-accent" />
<SearchHighlight text={node.name} query={query} />
</div>
</button>
)}
</li>
);
@@ -169,11 +195,19 @@ export function ModelStructureTree({
bodies,
joints,
onJointHover,
onSelectBody,
onSelectJoint,
selectedBodyId,
selectedJointId,
query = '',
}: {
bodies: BodyInfo[];
joints: JointInfo[];
onJointHover: (jointId: number | null) => void;
onSelectBody?: (bodyId: number) => void;
onSelectJoint?: (joint: JointInfo) => void;
selectedBodyId?: number;
selectedJointId?: number;
query?: string;
}) {
const normalized = query.trim().toLocaleLowerCase(),
@@ -211,8 +245,12 @@ export function ModelStructureTree({
renderRow={(item) =>
item.kind === 'body' ? (
<div
aria-selected={selectedBodyId === item.body.id}
onClick={() => onSelectBody?.(item.body.id)}
onDoubleClick={() => toggle(item)}
className="flex h-full items-center gap-1.5 px-1.5 text-xs text-text-secondary"
className={`flex h-full items-center gap-1.5 px-1.5 text-xs ${
selectedBodyId === item.body.id ? 'text-accent' : 'text-text-secondary'
}`}
style={{ paddingLeft: item.depth * 12 + 6 }}
>
<Box className="h-3.5 w-3.5 text-accent" />
@@ -220,8 +258,12 @@ export function ModelStructureTree({
</div>
) : (
<div
className="flex h-full items-center gap-1.5 px-1.5 text-xs text-warning"
aria-selected={selectedJointId === item.joint.id}
className={`flex h-full items-center gap-1.5 px-1.5 text-xs text-warning ${
selectedJointId === item.joint.id ? 'bg-warning-soft' : ''
}`}
style={{ paddingLeft: item.depth * 12 + 6 }}
onClick={() => onSelectJoint?.(item.joint)}
onMouseEnter={() => onJointHover(item.joint.id)}
onMouseLeave={() => onJointHover(null)}
>
@@ -244,6 +286,10 @@ export function ModelStructureTree({
node={root}
depth={0}
onJointHover={onJointHover}
onSelectBody={onSelectBody}
onSelectJoint={onSelectJoint}
selectedBodyId={selectedBodyId}
selectedJointId={selectedJointId}
searching={Boolean(normalized)}
query={query}
/>
+25
View File
@@ -2,6 +2,7 @@ import { zipSync } from 'fflate';
import {
choosePreferredEntry,
discoverEntries,
filesFromDrop,
importBrowserFiles,
normalizeProjectPath,
prepareProjectForMujoco,
@@ -35,6 +36,30 @@ describe('project importer', () => {
expect(entries).toHaveLength(3);
expect(choosePreferredEntry(entries)).toBe('model.xml');
});
it('通过现代文件系统句柄递归读取拖入的文件夹', async () => {
const model = new File(['<mujoco/>'], 'model.xml', { type: 'text/xml' });
const fileHandle = {
kind: 'file',
name: 'model.xml',
getFile: async () => model,
};
const directoryHandle = {
kind: 'directory',
name: 'robot',
async *values() {
yield fileHandle;
},
};
const items = [
{
kind: 'file',
getAsFileSystemHandle: async () => directoryHandle,
},
] as unknown as DataTransferItemList;
const files = await filesFromDrop(items, [] as unknown as FileList);
expect(files).toHaveLength(1);
expect(files[0].webkitRelativePath).toBe('robot/model.xml');
});
it('异步解压 ZIP、保留二进制数据并报告阶段进度', async () => {
const zipped = zipSync({
'robot/model.urdf': encode('<robot name="r"/>'),
+44 -1
View File
@@ -427,6 +427,29 @@ export async function importBrowserFiles(
return manifest(files[0].path.split('/')[0] || '工程', files);
}
interface DroppedFileSystemHandle {
kind: 'file' | 'directory';
name: string;
getFile?: () => Promise<File>;
values?: () => AsyncIterableIterator<DroppedFileSystemHandle>;
}
async function readFileSystemHandle(handle: DroppedFileSystemHandle, prefix = ''): Promise<File[]> {
if (handle.kind === 'file' && handle.getFile) {
const file = await handle.getFile();
Object.defineProperty(file, 'webkitRelativePath', {
configurable: true,
value: `${prefix}${file.name}`,
});
return [file];
}
if (handle.kind !== 'directory' || !handle.values) return [];
const files: File[] = [];
for await (const child of handle.values())
files.push(...(await readFileSystemHandle(child, `${prefix}${handle.name}/`)));
return files;
}
interface LegacyEntry {
isFile: boolean;
isDirectory: boolean;
@@ -464,7 +487,27 @@ export async function filesFromDrop(
items: DataTransferItemList,
fallback: FileList,
): Promise<File[]> {
const entries = Array.from(items)
const fileItems = Array.from(items).filter((item) => item.kind === 'file');
const handleRequests = fileItems
.map((item) => {
const getter = (
item as unknown as {
getAsFileSystemHandle?: () => Promise<DroppedFileSystemHandle | null>;
}
).getAsFileSystemHandle;
return getter?.call(item);
})
.filter((request): request is Promise<DroppedFileSystemHandle | null> => Boolean(request));
if (handleRequests.length > 0 && handleRequests.length === fileItems.length) {
const settled = await Promise.allSettled(handleRequests);
const handles = settled.flatMap((result) =>
result.status === 'fulfilled' && result.value ? [result.value] : [],
);
if (handles.length === fileItems.length)
return (await Promise.all(handles.map((handle) => readFileSystemHandle(handle)))).flat();
}
const entries = fileItems
.map(
(item) =>
(item as unknown as { webkitGetAsEntry?: () => LegacyEntry | null }).webkitGetAsEntry?.() ??