Files
Mujoco_WASM/web_platform/src/app/components/SourceEditorDialog.tsx
T
chenlin cb3fb47561
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
feat(web-platform): release V0.9.4 前端设计优化
2026-09-09 13:15:46 +08:00

268 lines
10 KiB
TypeScript

import { FloatingLayerContext, useFloatingLayer } from '../../components/ui/floating';
import './monacoSetup';
import Editor from '@monaco-editor/react';
import {
useCallback,
useEffect,
useRef,
useState,
type PointerEvent as ReactPointerEvent,
} from 'react';
import { Check, Code2, Copy, Download, Maximize2, Minimize2, Save, X } from 'lucide-react';
import { downloadBytes } from '../../project/cachedFiles';
import { Button, ConfirmDialog, IconButton } from '../../components/ui';
function basename(path: string): string {
return path.split('/').at(-1) ?? path;
}
function contentSize(content: string): string {
const bytes = new Blob([content]).size;
return bytes < 1024 ? `${bytes} B` : `${(bytes / 1024).toFixed(1)} KB`;
}
function xmlProblem(code: string): string | undefined {
const document = new DOMParser().parseFromString(code, 'application/xml'),
error = document.querySelector('parsererror');
return error?.textContent?.split('\n')[0] || undefined;
}
export function SourceEditorDialog({
open,
code: sourceCode,
filePath,
theme,
onClose,
onSave,
}: {
open: boolean;
code: string;
filePath: string;
theme: 'light' | 'dark';
onClose: () => void;
onSave: (path: string, text: string) => void | Promise<void>;
}) {
const [code, setCode] = useState(sourceCode),
[savedCode, setSavedCode] = useState(sourceCode),
[saving, setSaving] = useState(false),
[saveError, setSaveError] = useState<string>(),
[copied, setCopied] = useState(false),
[maximized, setMaximized] = useState(false),
[discardOpen, setDiscardOpen] = useState(false),
[position, setPosition] = useState(() => ({
x: Math.max(24, (window.innerWidth - 900) / 2),
y: Math.max(52, (window.innerHeight - 650) / 2),
}));
const drag = useRef<{ x: number; y: number; left: number; top: number } | null>(null),
dialog = useRef<HTMLElement>(null),
previousFocus = useRef<HTMLElement | null>(null),
dirty = code !== savedCode,
problem = xmlProblem(code);
const requestClose = useCallback(() => {
if (dirty) setDiscardOpen(true);
else onClose();
}, [dirty, onClose]);
const save = useCallback(async () => {
if (!dirty || problem) return;
setSaving(true);
setSaveError(undefined);
try {
await onSave(filePath, code);
setSavedCode(code);
} catch (value) {
setSaveError(value instanceof Error ? value.message : String(value));
} finally {
setSaving(false);
}
}, [code, dirty, filePath, onSave, problem]);
const layer = useFloatingLayer({ open, roots: () => [dialog.current], dismiss: requestClose });
useEffect(() => {
if (!open) return;
previousFocus.current =
document.activeElement instanceof HTMLElement ? document.activeElement : null;
requestAnimationFrame(() => dialog.current?.focus());
return () => {
if (previousFocus.current && document.contains(previousFocus.current))
previousFocus.current.focus();
};
}, [open]);
useEffect(() => {
const key = (event: KeyboardEvent) => {
if (!open || discardOpen || !dialog.current?.contains(document.activeElement)) return;
if (
(event.ctrlKey || event.metaKey) &&
event.key.toLowerCase() === 's' &&
dirty &&
!problem
) {
event.preventDefault();
void save();
}
};
window.addEventListener('keydown', key);
return () => window.removeEventListener('keydown', key);
}, [open, dirty, discardOpen, problem, requestClose, save]);
const copy = async () => {
await navigator.clipboard.writeText(code);
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
};
const download = () =>
downloadBytes(new TextEncoder().encode(code), basename(filePath), 'application/xml');
const pointerDown = (event: ReactPointerEvent) => {
if (maximized || event.button !== 0 || (event.target as HTMLElement).closest('button')) return;
drag.current = { x: event.clientX, y: event.clientY, left: position.x, top: position.y };
event.currentTarget.setPointerCapture(event.pointerId);
};
const pointerMove = (event: ReactPointerEvent) => {
if (!drag.current) return;
setPosition({
x: Math.min(
window.innerWidth - 120,
Math.max(-780, drag.current.left + event.clientX - drag.current.x),
),
y: Math.min(
window.innerHeight - 48,
Math.max(0, drag.current.top + event.clientY - drag.current.y),
),
});
};
if (!open) return null;
return (
<FloatingLayerContext.Provider value={layer.context}>
<div className="fixed inset-0 z-[390] pointer-events-none" role="presentation">
<section
ref={dialog}
tabIndex={-1}
role="dialog"
aria-modal="false"
aria-label="转换后的 MJCF 编辑器"
style={
maximized
? { zIndex: layer.zIndex }
: {
zIndex: layer.zIndex,
left: `clamp(16px, ${position.x}px, max(16px, calc(100vw - 916px)))`,
top: `clamp(16px, ${position.y}px, max(16px, calc(100vh - 666px)))`,
width: 'min(900px, calc(100vw - 32px))',
height: 'min(650px, calc(100vh - 32px))',
}
}
className={`source-editor-window pointer-events-auto fixed flex min-h-64 min-w-0 max-w-[calc(100vw-32px)] max-h-[calc(100vh-32px)] flex-col overflow-hidden border border-border-strong ${maximized ? 'inset-0 h-full w-full' : 'resize'}`}
>
<header
className="flex min-h-11 flex-wrap shrink-0 py-2 cursor-move select-none items-center gap-3 border-b border-border bg-surface px-3"
onPointerDown={pointerDown}
onPointerMove={pointerMove}
onPointerUp={() => {
drag.current = null;
}}
onDoubleClick={() => setMaximized((value) => !value)}
>
<Code2 className="h-4 w-4 shrink-0 text-accent" />
<div className="min-w-0 flex-1">
<div className="truncate font-mono text-xs font-semibold text-text-primary">
转换后的 MJCF
</div>
<div className="break-all font-mono text-xs text-text-tertiary" title={filePath}>
{filePath}
</div>
</div>
<span className="text-xs text-text-tertiary">{contentSize(code)}</span>
<span className="rounded bg-accent-soft px-1.5 py-0.5 text-xs font-semibold text-accent">
缓存文件 · 可编辑
</span>
{dirty && (
<span className="rounded bg-warning-soft px-1.5 py-0.5 text-xs font-semibold text-warning">
已修改
</span>
)}
<Button
variant="primary"
icon={<Save className="h-3 w-3" />}
disabled={!dirty || saving || Boolean(problem)}
onClick={() => void save()}
>
{saving ? '重新载入中…' : '保存并重新载入'}
</Button>
<Button variant="ghost" icon={<Download className="h-3.5 w-3.5" />} onClick={download}>
下载
</Button>
<Button
variant="ghost"
icon={copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
onClick={() => void copy()}
>
{copied ? '已复制' : '复制'}
</Button>
<IconButton
tooltip={maximized ? '还原' : '最大化'}
aria-label={maximized ? '还原' : '最大化'}
onClick={() => setMaximized((value) => !value)}
>
{maximized ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
</IconButton>
<IconButton tooltip="关闭" aria-label="关闭源代码编辑器" onClick={requestClose}>
<X className="h-4 w-4" />
</IconButton>
</header>
<p className="border-b border-border px-3 py-2 text-xs text-warning">
保存将重新编译并载入模型;未保存的源码关闭后无法恢复。
</p>
{saveError && (
<p role="alert" className="px-3 py-2 text-xs text-danger">
{saveError}
</p>
)}
<div className="min-h-0 flex-1 bg-input">
<Editor
height="100%"
language="xml"
theme={theme === 'light' ? 'light' : 'vs-dark'}
value={code}
onChange={(value) => setCode(value ?? '')}
options={{
automaticLayout: true,
minimap: { enabled: false },
fontFamily: "'JetBrains Mono','Fira Code',ui-monospace,monospace",
fontSize: 13,
fontLigatures: true,
scrollBeyondLastLine: false,
wordWrap: 'off',
stickyScroll: { enabled: false },
tabSize: 2,
formatOnPaste: true,
formatOnType: true,
lineNumbersMinChars: 4,
padding: { top: 12, bottom: 14 },
renderLineHighlight: 'all',
}}
/>
</div>
<footer className="flex min-h-7 flex-wrap shrink-0 items-center justify-between gap-3 border-t border-border bg-surface px-3 text-xs">
<div className={problem ? 'break-words text-warning' : 'text-success'}>
{problem ? `XML 错误:${problem}` : '✓ XML 结构正常'}
</div>
<div className="flex items-center gap-2 font-mono text-text-tertiary">
<span>Ctrl+S 保存并重新载入</span>
<span></span>
<span>MJCF / XML</span>
</div>
</footer>
</section>
</div>
<ConfirmDialog
open={discardOpen}
title="放弃未保存的修改?"
confirmLabel="放弃修改"
cancelLabel="继续编辑"
danger
onConfirm={onClose}
onClose={() => setDiscardOpen(false)}
>
<p className="text-sm text-text-secondary">
当前 MJCF 源码包含未保存的修改。关闭后,这些修改将无法恢复。
</p>
</ConfirmDialog>
</FloatingLayerContext.Provider>
);
}