203 lines
6.5 KiB
TypeScript
203 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="engineering-glass map-tool-enter absolute left-1/2 top-3 z-20 flex max-w-[calc(100%-24px)] -translate-x-1/2 items-center gap-1.5 rounded-xl border p-1.5"
|
|
>
|
|
<button
|
|
type="button"
|
|
aria-label="激活地图编辑"
|
|
aria-pressed={interactionActive}
|
|
disabled={loading}
|
|
onClick={onActivate}
|
|
className={`hidden h-8 items-center gap-1.5 rounded-lg px-2 text-[10px] font-semibold transition-colors sm:flex ${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-[10px] text-text-tertiary">
|
|
<span className="hidden lg:inline">贴地</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,
|
|
}: {
|
|
visible: boolean;
|
|
changeCount: number;
|
|
loading: boolean;
|
|
onCommit: () => void;
|
|
onDiscard: () => void;
|
|
}) {
|
|
if (!visible) return null;
|
|
const dirty = changeCount > 0;
|
|
return (
|
|
<div
|
|
aria-label="地图草稿状态"
|
|
role="status"
|
|
className="engineering-glass absolute bottom-3 left-1/2 z-20 flex max-w-[calc(100%-24px)] -translate-x-1/2 items-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 whitespace-nowrap text-[10px] font-medium text-text-secondary sm:text-[11px]">
|
|
{dirty ? `${changeCount} 项未保存改动` : '地图草稿已同步'}
|
|
</span>
|
|
<Button
|
|
variant="ghost"
|
|
aria-label="丢弃地图草稿"
|
|
disabled={loading || !dirty}
|
|
icon={<Trash2 className="h-3 w-3" />}
|
|
onClick={onDiscard}
|
|
>
|
|
<span className="hidden sm:inline">丢弃</span>
|
|
</Button>
|
|
<Button
|
|
variant="primary"
|
|
aria-label="提交地图草稿"
|
|
disabled={loading || !dirty}
|
|
icon={<Check className="h-3 w-3" />}
|
|
onClick={onCommit}
|
|
>
|
|
<span className="hidden sm:inline">提交</span>
|
|
</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-[10px] font-medium shadow-xl backdrop-blur">
|
|
{valid
|
|
? `释放放置 · ${target.position![0].toFixed(1)}, ${target.position![1].toFixed(1)}`
|
|
: '请拖到 3D 地面'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|