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
268 lines
10 KiB
TypeScript
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>
|
|
);
|
|
}
|