Files
Mujoco_WASM/web_platform/src/app/components/MapViewportTools.tsx
T
chenlin 13e35be98b
web-platform-ci / TypeScript, lint, unit, build (push) Has been cancelled
web-platform-ci / Playwright E2E (push) Has been cancelled
feat(web-platform): release V0.9.3 全模块界面重构
2026-09-08 17:57:57 +08:00

210 lines
6.5 KiB
TypeScript

import {
ArrowDownToLine,
Check,
Grid3X3,
LockKeyhole,
MapPinned,
Move3d,
Rotate3d,
Scaling,
Trash2,
} from 'lucide-react';
import {
Button,
IconButton,
Select,
ToolbarToggleGroup,
type ToolbarItem,
} from '../../components/ui';
import {
MAP_OBJECT_PLACEMENT_LABELS,
type MapEditorTransformMode,
type MapObjectPlacementMode,
} from '../../map/editor/types';
const TRANSFORM_TOOLS: ToolbarItem<MapEditorTransformMode>[] = [
{ value: 'translate', label: '移动工具 W', icon: Move3d },
{ value: 'rotate', label: '旋转工具 E', icon: Rotate3d },
{ value: 'scale', label: '缩放工具 R', icon: Scaling },
];
export function MapViewportToolbar({
visible,
interactionActive,
mode,
snapping,
placementMode,
hasSelectedObject,
allowScale,
loading,
onActivate,
onModeChange,
onSnappingChange,
onPlacementModeChange,
onAlignToSurface,
}: {
visible: boolean;
interactionActive: boolean;
mode: MapEditorTransformMode;
snapping: boolean;
placementMode: MapObjectPlacementMode;
hasSelectedObject: boolean;
allowScale: boolean;
loading: boolean;
onActivate: () => void;
onModeChange: (mode: MapEditorTransformMode) => void;
onSnappingChange: (value: boolean) => void;
onPlacementModeChange: (mode: MapObjectPlacementMode) => void;
onAlignToSurface: () => void;
}) {
if (!visible) return null;
return (
<div
aria-label="地图视口工具"
className="flex max-w-full flex-wrap items-center justify-center gap-1 p-1"
>
<button
type="button"
aria-label="激活地图编辑"
aria-pressed={interactionActive}
disabled={loading}
onClick={onActivate}
className={`flex h-8 items-center gap-1.5 rounded-lg px-2 text-xs font-semibold transition-colors ${interactionActive ? 'bg-accent-soft text-accent' : 'bg-surface/80 text-text-secondary hover:bg-element-hover'}`}
>
<MapPinned className="h-3.5 w-3.5" aria-hidden="true" />
地图编辑
</button>
<ToolbarToggleGroup
items={TRANSFORM_TOOLS.filter((item) => allowScale || item.value !== 'scale')}
value={mode}
onChange={onModeChange}
label="地图变换模式"
/>
<IconButton
active={snapping}
tooltip={snapping ? '关闭网格吸附(当前 0.1 m / 5°)' : '开启网格吸附'}
aria-label="网格吸附"
aria-pressed={snapping}
disabled={loading}
onClick={() => onSnappingChange(!snapping)}
>
<Grid3X3 className="h-3.5 w-3.5" />
</IconButton>
<span aria-hidden="true" className="mx-0.5 h-5 w-px bg-border" />
<label className="flex items-center gap-1 text-xs text-text-tertiary">
<span>贴地</span>
<Select
aria-label="贴地检测模式"
className="w-[112px]"
value={placementMode}
disabled={loading}
onChange={(event) => onPlacementModeChange(event.target.value as MapObjectPlacementMode)}
>
{(Object.keys(MAP_OBJECT_PLACEMENT_LABELS) as MapObjectPlacementMode[]).map((value) => (
<option key={value} value={value}>
{value === 'auto_ground' ? '地面 z=0' : value === 'gravity' ? '表面检测' : '锁定位姿'}
</option>
))}
</Select>
</label>
<IconButton
tooltip={hasSelectedObject ? '重新检测并贴合承载面' : '选中地图物体后可贴合表面'}
aria-label="立即贴合承载面"
disabled={loading || !hasSelectedObject || placementMode === 'locked'}
onClick={onAlignToSurface}
>
{placementMode === 'locked' ? (
<LockKeyhole className="h-3.5 w-3.5" />
) : (
<ArrowDownToLine className="h-3.5 w-3.5" />
)}
</IconButton>
</div>
);
}
export function MapDraftStatusOverlay({
visible,
changeCount,
loading,
onCommit,
onDiscard,
error,
}: {
visible: boolean;
changeCount: number;
loading: boolean;
error?: string;
onCommit: () => void;
onDiscard: () => void;
}) {
const dirty = changeCount > 0;
if (!visible || (!dirty && !loading && !error)) return null;
return (
<div
aria-label="地图草稿状态"
role="status"
className="engineering-glass flex max-w-full flex-wrap items-center justify-center gap-2 rounded-xl border px-2 py-1.5"
>
<span
aria-hidden="true"
className={`h-2 w-2 shrink-0 rounded-full ${dirty ? 'draft-dirty-dot bg-warning' : 'bg-success'}`}
/>
<span className="min-w-0 text-xs font-medium text-text-secondary">
{loading
? '正在应用草稿…'
: error
? `应用失败 · ${changeCount} 项未保存改动`
: `${changeCount} 项未保存改动`}
{error && <span className="block text-danger">{error}</span>}
</span>
<Button
variant="ghost"
aria-label="丢弃地图草稿"
disabled={loading || !dirty}
icon={<Trash2 className="h-3 w-3" />}
onClick={onDiscard}
>
放弃
</Button>
<Button
variant="primary"
aria-label="提交地图草稿"
disabled={loading || !dirty}
icon={<Check className="h-3 w-3" />}
onClick={onCommit}
>
应用
</Button>
</div>
);
}
export interface MapAssetDropTarget {
left: number;
top: number;
position: readonly [number, number, number] | null;
}
export function MapAssetDropIndicator({ target }: { target?: MapAssetDropTarget }) {
if (!target) return null;
const valid = Boolean(target.position);
return (
<div className="pointer-events-none absolute inset-0 z-30" aria-label="地图资产放置预览">
<div
className={`absolute -translate-x-1/2 -translate-y-1/2 rounded-full border-2 p-1 shadow-2xl ${valid ? 'border-accent bg-accent/25 text-accent' : 'border-danger bg-danger/20 text-danger'}`}
style={{ left: target.left, top: target.top }}
>
<span className="grid h-8 w-8 place-items-center rounded-full border border-current bg-panel/85 backdrop-blur">
<MapPinned className="h-4 w-4" />
</span>
<span className="absolute left-1/2 top-full mt-2 -translate-x-1/2 whitespace-nowrap rounded-lg border border-border-strong bg-panel/90 px-2 py-1 text-xs font-medium shadow-xl backdrop-blur">
{valid
? `释放放置 · ${target.position![0].toFixed(1)}, ${target.position![1].toFixed(1)}`
: '请拖到 3D 地面'}
</span>
</div>
</div>
);
}